From b71ebec1db584b69f64e8a204ef9fe573572279a Mon Sep 17 00:00:00 2001 From: Basak Akan Date: Tue, 3 Oct 2023 13:00:51 +0200 Subject: [PATCH 01/88] make lti user login again if email exists --- .../artemis/service/connectors/lti/LtiService.java | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/src/main/java/de/tum/in/www1/artemis/service/connectors/lti/LtiService.java b/src/main/java/de/tum/in/www1/artemis/service/connectors/lti/LtiService.java index 32ac14e5fcea..db264d67c17d 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/connectors/lti/LtiService.java +++ b/src/main/java/de/tum/in/www1/artemis/service/connectors/lti/LtiService.java @@ -97,11 +97,11 @@ public void authenticateLtiUser(String email, String username, String firstName, } } - // 2. Case: Lookup user with the LTI email address and make sure it's not in use + // 2. Case: Lookup user with the LTI email address and make user login again final var usernameLookupByEmail = artemisAuthenticationProvider.getUsernameForEmail(email); if (usernameLookupByEmail.isPresent()) { - throw new InternalAuthenticationServiceException( - "Email address is already in use by Artemis. Please use a different address with your service or contact your instructor to gain direct access."); + SecurityContextHolder.getContext().setAuthentication(getUserFromLaunchRequest(email)); + return; } // 3. Case: Create new user if an existing user is not required @@ -139,6 +139,13 @@ private Authentication createNewUserFromLaunchRequest(String email, String usern return new UsernamePasswordAuthenticationToken(user.getLogin(), user.getPassword(), SIMPLE_USER_LIST_AUTHORITY); } + @NotNull + private Authentication getUserFromLaunchRequest(String email) { + final var user = userRepository.findOneByEmailIgnoreCase(email).orElseThrow(); + log.info("Signing in as {}", user.getLogin()); + return UsernamePasswordAuthenticationToken.unauthenticated(user.getLogin(), user.getPassword()); + } + /** * Handler for successful LTI auth. Adds the groups to the user * From b0b05e3acd2cd37b5f6d18130b7d62999a152004 Mon Sep 17 00:00:00 2001 From: Basak Akan Date: Tue, 3 Oct 2023 13:07:39 +0200 Subject: [PATCH 02/88] throw exception when cannot find user --- .../tum/in/www1/artemis/service/connectors/lti/LtiService.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/de/tum/in/www1/artemis/service/connectors/lti/LtiService.java b/src/main/java/de/tum/in/www1/artemis/service/connectors/lti/LtiService.java index db264d67c17d..8b0f898c0a46 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/connectors/lti/LtiService.java +++ b/src/main/java/de/tum/in/www1/artemis/service/connectors/lti/LtiService.java @@ -141,7 +141,7 @@ private Authentication createNewUserFromLaunchRequest(String email, String usern @NotNull private Authentication getUserFromLaunchRequest(String email) { - final var user = userRepository.findOneByEmailIgnoreCase(email).orElseThrow(); + final var user = userRepository.findOneByEmailIgnoreCase(email).orElseThrow(() -> new InternalAuthenticationServiceException("User not found with email: " + email)); log.info("Signing in as {}", user.getLogin()); return UsernamePasswordAuthenticationToken.unauthenticated(user.getLogin(), user.getPassword()); } From 7b54a3bc68cb92da3faaed09309872209d4500d2 Mon Sep 17 00:00:00 2001 From: Basak Akan Date: Tue, 3 Oct 2023 21:49:58 +0200 Subject: [PATCH 03/88] adapt authenticate newuser server test --- .../tum/in/www1/artemis/connectors/LtiServiceTest.java | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/test/java/de/tum/in/www1/artemis/connectors/LtiServiceTest.java b/src/test/java/de/tum/in/www1/artemis/connectors/LtiServiceTest.java index e18655b19189..1765a6c5d808 100644 --- a/src/test/java/de/tum/in/www1/artemis/connectors/LtiServiceTest.java +++ b/src/test/java/de/tum/in/www1/artemis/connectors/LtiServiceTest.java @@ -202,10 +202,13 @@ void authenticateLtiUser_newUser() { SecurityContextHolder.getContext().setAuthentication(null); when(artemisAuthenticationProvider.getUsernameForEmail("email")).thenReturn(Optional.of("username")); + when(userRepository.findOneByEmailIgnoreCase("email")).thenReturn(Optional.ofNullable(user)); - assertThatExceptionOfType(InternalAuthenticationServiceException.class) - .isThrownBy(() -> ltiService.authenticateLtiUser("email", "username", "firstname", "lastname", onlineCourseConfiguration.isRequireExistingUser())) - .withMessageContaining("already in use"); + ltiService.authenticateLtiUser("email", "username", "firstname", "lastname", onlineCourseConfiguration.isRequireExistingUser()); + + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + assertThat(auth.getPrincipal()).isEqualTo(user.getLogin()); + assertThat(user.getGroups()).contains(LtiService.LTI_GROUP_NAME); } @Test From 3571ede87d8fd2f631f4e1ce7bae32e5a4d789f3 Mon Sep 17 00:00:00 2001 From: Basak Akan Date: Thu, 5 Oct 2023 21:55:48 +0200 Subject: [PATCH 04/88] add new handling for not logged in users --- .../security/lti/Lti13LaunchFilter.java | 8 +++++ .../service/connectors/lti/Lti13Service.java | 13 +++++++++ .../service/connectors/lti/LtiService.java | 12 ++------ .../web/rest/open/PublicLtiResource.java | 8 +++++ .../lti/lti13-exercise-launch.component.ts | 29 ++++++++++++++++--- 5 files changed, 56 insertions(+), 14 deletions(-) diff --git a/src/main/java/de/tum/in/www1/artemis/security/lti/Lti13LaunchFilter.java b/src/main/java/de/tum/in/www1/artemis/security/lti/Lti13LaunchFilter.java index c29b4584e56a..303862f593e3 100644 --- a/src/main/java/de/tum/in/www1/artemis/security/lti/Lti13LaunchFilter.java +++ b/src/main/java/de/tum/in/www1/artemis/security/lti/Lti13LaunchFilter.java @@ -11,6 +11,7 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.context.annotation.Profile; +import org.springframework.security.authentication.InternalAuthenticationServiceException; import org.springframework.security.oauth2.core.OAuth2AuthenticationException; import org.springframework.security.oauth2.core.oidc.OidcIdToken; import org.springframework.security.oauth2.core.oidc.user.OidcUser; @@ -58,6 +59,10 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse OidcAuthenticationToken authToken = finishOidcFlow(request, response); OidcIdToken ltiIdToken = ((OidcUser) authToken.getPrincipal()).getIdToken(); + + // get username from client to set authentication + lti13Service.setAuthenticationFromClient(request); + lti13Service.performLaunch(ltiIdToken, authToken.getAuthorizedClientRegistrationId()); writeResponse(ltiIdToken.getClaim(Claims.TARGET_LINK_URI), response); @@ -66,6 +71,9 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse log.error("Error during LTI 1.3 launch request: {}", ex.getMessage()); response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, "LTI 1.3 Launch failed"); } + catch (InternalAuthenticationServiceException ex) { + response.sendError(HttpServletResponse.SC_UNAUTHORIZED, ex.getMessage()); + } } private OidcAuthenticationToken finishOidcFlow(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { diff --git a/src/main/java/de/tum/in/www1/artemis/service/connectors/lti/Lti13Service.java b/src/main/java/de/tum/in/www1/artemis/service/connectors/lti/Lti13Service.java index 7697abf862d6..b9610b897854 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/connectors/lti/Lti13Service.java +++ b/src/main/java/de/tum/in/www1/artemis/service/connectors/lti/Lti13Service.java @@ -1,5 +1,7 @@ package de.tum.in.www1.artemis.service.connectors.lti; +import static de.tum.in.www1.artemis.service.connectors.lti.LtiService.SIMPLE_USER_LIST_AUTHORITY; + import java.net.MalformedURLException; import java.net.URL; import java.util.Collection; @@ -7,6 +9,7 @@ import java.util.Optional; import java.util.stream.Collectors; +import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import javax.validation.constraints.NotNull; @@ -17,6 +20,8 @@ import org.springframework.http.HttpEntity; import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.oauth2.client.registration.ClientRegistration; import org.springframework.security.oauth2.core.oidc.OidcIdToken; import org.springframework.stereotype.Service; @@ -295,4 +300,12 @@ private void createOrUpdateResourceLaunch(Lti13LaunchRequest launchRequest, User public void buildLtiResponse(UriComponentsBuilder uriComponentsBuilder, HttpServletResponse response) { ltiService.buildLtiResponse(uriComponentsBuilder, response); } + + public void setAuthenticationFromClient(HttpServletRequest request) { + var username = request.getParameter("auth"); + if (username != null) { + var user = userRepository.findOneByLogin(username).orElseThrow(); + SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(user.getLogin(), user.getPassword(), SIMPLE_USER_LIST_AUTHORITY)); + } + } } diff --git a/src/main/java/de/tum/in/www1/artemis/service/connectors/lti/LtiService.java b/src/main/java/de/tum/in/www1/artemis/service/connectors/lti/LtiService.java index 8b0f898c0a46..b8819ce035c8 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/connectors/lti/LtiService.java +++ b/src/main/java/de/tum/in/www1/artemis/service/connectors/lti/LtiService.java @@ -97,11 +97,10 @@ public void authenticateLtiUser(String email, String username, String firstName, } } - // 2. Case: Lookup user with the LTI email address and make user login again + // 2. Case: Lookup user with the LTI email address and make sure it's not in use final var usernameLookupByEmail = artemisAuthenticationProvider.getUsernameForEmail(email); if (usernameLookupByEmail.isPresent()) { - SecurityContextHolder.getContext().setAuthentication(getUserFromLaunchRequest(email)); - return; + throw new InternalAuthenticationServiceException("Please login again as " + usernameLookupByEmail); } // 3. Case: Create new user if an existing user is not required @@ -139,13 +138,6 @@ private Authentication createNewUserFromLaunchRequest(String email, String usern return new UsernamePasswordAuthenticationToken(user.getLogin(), user.getPassword(), SIMPLE_USER_LIST_AUTHORITY); } - @NotNull - private Authentication getUserFromLaunchRequest(String email) { - final var user = userRepository.findOneByEmailIgnoreCase(email).orElseThrow(() -> new InternalAuthenticationServiceException("User not found with email: " + email)); - log.info("Signing in as {}", user.getLogin()); - return UsernamePasswordAuthenticationToken.unauthenticated(user.getLogin(), user.getPassword()); - } - /** * Handler for successful LTI auth. Adds the groups to the user * diff --git a/src/main/java/de/tum/in/www1/artemis/web/rest/open/PublicLtiResource.java b/src/main/java/de/tum/in/www1/artemis/web/rest/open/PublicLtiResource.java index 0cd72162c321..4928065a358b 100644 --- a/src/main/java/de/tum/in/www1/artemis/web/rest/open/PublicLtiResource.java +++ b/src/main/java/de/tum/in/www1/artemis/web/rest/open/PublicLtiResource.java @@ -12,6 +12,7 @@ import org.springframework.context.annotation.Profile; import org.springframework.http.HttpStatus; import org.springframework.security.authentication.InternalAuthenticationServiceException; +import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.web.bind.annotation.ModelAttribute; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; @@ -24,6 +25,7 @@ import de.tum.in.www1.artemis.domain.OnlineCourseConfiguration; import de.tum.in.www1.artemis.repository.CourseRepository; import de.tum.in.www1.artemis.repository.ExerciseRepository; +import de.tum.in.www1.artemis.security.SecurityUtils; import de.tum.in.www1.artemis.security.annotations.EnforceNothing; import de.tum.in.www1.artemis.service.connectors.lti.Lti10Service; import de.tum.in.www1.artemis.web.rest.dto.LtiLaunchRequestDTO; @@ -155,10 +157,16 @@ public void lti13LaunchRedirect(HttpServletRequest request, HttpServletResponse return; } + String userName = ""; + if (SecurityUtils.isAuthenticated()) { + userName = SecurityContextHolder.getContext().getAuthentication().getName(); + } + UriComponentsBuilder uriBuilder = buildRedirect(request); uriBuilder.path(LOGIN_REDIRECT_CLIENT_PATH); uriBuilder.queryParam("state", UriComponent.encode(state, UriComponent.Type.QUERY_PARAM)); uriBuilder.queryParam("id_token", UriComponent.encode(idToken, UriComponent.Type.QUERY_PARAM)); + uriBuilder.queryParam("auth", UriComponent.encode(userName, UriComponent.Type.QUERY_PARAM)); String redirectUrl = uriBuilder.build().toString(); log.info("redirect to url: {}", redirectUrl); response.sendRedirect(redirectUrl); // Redirect using user-provided values is safe because user-provided values are used in the query parameters, not the url itself diff --git a/src/main/webapp/app/lti/lti13-exercise-launch.component.ts b/src/main/webapp/app/lti/lti13-exercise-launch.component.ts index cd0e3e15a6e1..892fffd7cd44 100644 --- a/src/main/webapp/app/lti/lti13-exercise-launch.component.ts +++ b/src/main/webapp/app/lti/lti13-exercise-launch.component.ts @@ -1,6 +1,8 @@ import { Component, OnInit } from '@angular/core'; import { ActivatedRoute } from '@angular/router'; import { HttpClient, HttpHeaders, HttpParams } from '@angular/common/http'; +import { LoginService } from 'app/core/login/login.service'; +import { AccountService } from 'app/core/auth/account.service'; @Component({ selector: 'jhi-lti-exercise-launch', @@ -12,6 +14,8 @@ export class Lti13ExerciseLaunchComponent implements OnInit { constructor( private route: ActivatedRoute, private http: HttpClient, + private loginService: LoginService, + private accountService: AccountService, ) { this.isLaunching = true; } @@ -20,8 +24,13 @@ export class Lti13ExerciseLaunchComponent implements OnInit { * perform an LTI launch with state and id_token query parameters */ ngOnInit(): void { + this.sendRequest(); + } + + sendRequest(): void { const state = this.route.snapshot.queryParamMap.get('state'); const idToken = this.route.snapshot.queryParamMap.get('id_token'); + const auth = this.route.snapshot.queryParamMap.get('auth'); if (!state || !idToken) { console.error('Required parameter for LTI launch missing'); @@ -39,7 +48,8 @@ export class Lti13ExerciseLaunchComponent implements OnInit { return; } - const requestBody = new HttpParams().set('state', state).set('id_token', idToken); + let requestBody = new HttpParams().set('state', state).set('id_token', idToken); + if (auth) requestBody = requestBody.set('auth', auth); this.http .post('api/public/lti13/auth-login', requestBody.toString(), { @@ -57,9 +67,20 @@ export class Lti13ExerciseLaunchComponent implements OnInit { console.error('No LTI targetLinkUri received for a successful launch'); } }, - error: () => { - window.sessionStorage.removeItem('state'); - this.isLaunching = false; + error: (error) => { + if (error.status === 401) { + this.loginService.logout(false); + // Subscribe to the authentication state to know when the user logs in + this.accountService.getAuthenticationState().subscribe((account) => { + if (account) { + // resend request when user logs in again + this.sendRequest(); + } + }); + } else { + window.sessionStorage.removeItem('state'); + this.isLaunching = false; + } }, }); } From c657f5e7b9df628e6122f64ad32378439a0daacc Mon Sep 17 00:00:00 2001 From: Basak Akan Date: Thu, 5 Oct 2023 22:48:47 +0200 Subject: [PATCH 05/88] fix server test --- .../de/tum/in/www1/artemis/connectors/LtiServiceTest.java | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/test/java/de/tum/in/www1/artemis/connectors/LtiServiceTest.java b/src/test/java/de/tum/in/www1/artemis/connectors/LtiServiceTest.java index 1765a6c5d808..a7a73291b51a 100644 --- a/src/test/java/de/tum/in/www1/artemis/connectors/LtiServiceTest.java +++ b/src/test/java/de/tum/in/www1/artemis/connectors/LtiServiceTest.java @@ -204,11 +204,8 @@ void authenticateLtiUser_newUser() { when(artemisAuthenticationProvider.getUsernameForEmail("email")).thenReturn(Optional.of("username")); when(userRepository.findOneByEmailIgnoreCase("email")).thenReturn(Optional.ofNullable(user)); - ltiService.authenticateLtiUser("email", "username", "firstname", "lastname", onlineCourseConfiguration.isRequireExistingUser()); - - Authentication auth = SecurityContextHolder.getContext().getAuthentication(); - assertThat(auth.getPrincipal()).isEqualTo(user.getLogin()); - assertThat(user.getGroups()).contains(LtiService.LTI_GROUP_NAME); + assertThatExceptionOfType(InternalAuthenticationServiceException.class) + .isThrownBy(() -> ltiService.authenticateLtiUser("email", "username", "firstname", "lastname", onlineCourseConfiguration.isRequireExistingUser())); } @Test From c6ba5ab8ad248c2e11416df273766f00698f625f Mon Sep 17 00:00:00 2001 From: Basak Akan Date: Fri, 6 Oct 2023 12:41:53 +0200 Subject: [PATCH 06/88] first structure implementation --- .../security/lti/Lti13LaunchFilter.java | 14 +++++- .../in/www1/artemis/web/rest/LtiResource.java | 8 ++++ src/main/webapp/app/lti/lti.route.ts | 8 ++++ .../app/lti/lti13-deep-linking.component.html | 1 + .../app/lti/lti13-deep-linking.component.ts | 44 +++++++++++++++++++ src/main/webapp/i18n/en/lti.json | 6 +++ 6 files changed, 79 insertions(+), 2 deletions(-) create mode 100644 src/main/webapp/app/lti/lti13-deep-linking.component.html create mode 100644 src/main/webapp/app/lti/lti13-deep-linking.component.ts diff --git a/src/main/java/de/tum/in/www1/artemis/security/lti/Lti13LaunchFilter.java b/src/main/java/de/tum/in/www1/artemis/security/lti/Lti13LaunchFilter.java index c29b4584e56a..24988609396d 100644 --- a/src/main/java/de/tum/in/www1/artemis/security/lti/Lti13LaunchFilter.java +++ b/src/main/java/de/tum/in/www1/artemis/security/lti/Lti13LaunchFilter.java @@ -58,9 +58,19 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse OidcAuthenticationToken authToken = finishOidcFlow(request, response); OidcIdToken ltiIdToken = ((OidcUser) authToken.getPrincipal()).getIdToken(); - lti13Service.performLaunch(ltiIdToken, authToken.getAuthorizedClientRegistrationId()); - writeResponse(ltiIdToken.getClaim(Claims.TARGET_LINK_URI), response); + // here we need to check if this is a deep-linking request or a launch request + if (ltiIdToken.getClaim(Claims.MESSAGE_TYPE) == "LtiDeepLinkingRequest") { + + writeResponse(ltiIdToken.getClaim(Claims.TARGET_LINK_URI), response); + // ltiIdToken.getClaim(Claims.DEEP_LINKING_SETTINGS) -- needed + } + else { + lti13Service.performLaunch(ltiIdToken, authToken.getAuthorizedClientRegistrationId()); + writeResponse(ltiIdToken.getClaim(Claims.TARGET_LINK_URI), response); + } + + // writeResponse(ltiIdToken.getClaim(Claims.TARGET_LINK_URI), response); } catch (HttpClientErrorException | OAuth2AuthenticationException | IllegalStateException ex) { log.error("Error during LTI 1.3 launch request: {}", ex.getMessage()); diff --git a/src/main/java/de/tum/in/www1/artemis/web/rest/LtiResource.java b/src/main/java/de/tum/in/www1/artemis/web/rest/LtiResource.java index c2d644b36085..95d7d182513f 100644 --- a/src/main/java/de/tum/in/www1/artemis/web/rest/LtiResource.java +++ b/src/main/java/de/tum/in/www1/artemis/web/rest/LtiResource.java @@ -41,4 +41,12 @@ public void lti13DynamicRegistration(@PathVariable Long courseId, @RequestParam( authCheckService.checkHasAtLeastRoleInCourseElseThrow(Role.INSTRUCTOR, course, null); ltiDynamicRegistrationService.performDynamicRegistration(course, openIdConfiguration, registrationToken); } + + @PostMapping("/lti13/deep-linking") + @EnforceAtLeastInstructor + public void lti13DynamicRegistration(String platformReturnUrl) { + + // here create a dummy response to send back to the platform + + } } diff --git a/src/main/webapp/app/lti/lti.route.ts b/src/main/webapp/app/lti/lti.route.ts index a4004dbe7903..9d2d6b1b98fc 100644 --- a/src/main/webapp/app/lti/lti.route.ts +++ b/src/main/webapp/app/lti/lti.route.ts @@ -1,6 +1,7 @@ import { Routes } from '@angular/router'; import { Lti13ExerciseLaunchComponent } from 'app/lti/lti13-exercise-launch.component'; import { Lti13DynamicRegistrationComponent } from 'app/lti/lti13-dynamic-registration.component'; +import { Lti13DeepLinkingComponent } from 'app/lti/lti13-deep-linking.component'; export const ltiLaunchRoutes: Routes = [ { @@ -17,6 +18,13 @@ export const ltiLaunchRoutes: Routes = [ pageTitle: 'artemisApp.lti13.dynamicRegistration.title', }, }, + { + path: 'deep-linking/:courseId', + component: Lti13DeepLinkingComponent, + data: { + pageTitle: 'artemisApp.lti13.deepLinking.title', + }, + }, ]; const LTI_LAUNCH_ROUTES = [...ltiLaunchRoutes]; diff --git a/src/main/webapp/app/lti/lti13-deep-linking.component.html b/src/main/webapp/app/lti/lti13-deep-linking.component.html new file mode 100644 index 000000000000..9f6d1e63f282 --- /dev/null +++ b/src/main/webapp/app/lti/lti13-deep-linking.component.html @@ -0,0 +1 @@ +

Linking...

diff --git a/src/main/webapp/app/lti/lti13-deep-linking.component.ts b/src/main/webapp/app/lti/lti13-deep-linking.component.ts new file mode 100644 index 000000000000..53d68ee786c0 --- /dev/null +++ b/src/main/webapp/app/lti/lti13-deep-linking.component.ts @@ -0,0 +1,44 @@ +import { Component, OnInit } from '@angular/core'; +import { ActivatedRoute } from '@angular/router'; +import { HttpClient } from '@angular/common/http'; + +@Component({ + selector: 'jhi-dynamic-registration', + templateUrl: './lti13-dynamic-registration.component.html', +}) +export class Lti13DeepLinkingComponent implements OnInit { + courseId: number; + isRegistering = true; + registeredSuccessfully: boolean; + + constructor( + private route: ActivatedRoute, + private http: HttpClient, + ) {} + + /** + * perform LTI 13 deep linking + */ + ngOnInit(): void { + this.route.params.subscribe((params) => { + this.courseId = Number(params['courseId']); + }); + + this.http.post('api/public/lti13/deep-linking', null); + + // this.http + // .post(`api/lti13/dynamic-registration/`, null, { observe: 'response', params: httpParams }) + // .subscribe({ + // next: () => { + // this.registeredSuccessfully = true; + // }, + // error: () => { + // this.registeredSuccessfully = false; + // }, + // }) + // .add(() => { + // this.isRegistering = false; + // (window.opener || window.parent).postMessage({ subject: 'org.imsglobal.lti.close' }, '*'); + // }); + } +} diff --git a/src/main/webapp/i18n/en/lti.json b/src/main/webapp/i18n/en/lti.json index 647c040dd91c..37ab48c3c739 100644 --- a/src/main/webapp/i18n/en/lti.json +++ b/src/main/webapp/i18n/en/lti.json @@ -75,6 +75,12 @@ "registeredSuccessfully": "Registered course successfully", "registerFailed": "Error during dynamic registration" }, + "deepLinking": { + "title": "Deep Linking", + "linking": "Linking", + "linkedSuccessfully": "Linked exercises successfully", + "linkedFailed": "Error during deep linking" + }, "missingConfigurationWarning": "Missing values in the LTI1.3 configuration. Launches will not work." } } From dbb9a448495db353d1abaac88d615083081ec834 Mon Sep 17 00:00:00 2001 From: Basak Akan Date: Fri, 6 Oct 2023 15:10:31 +0200 Subject: [PATCH 07/88] fix client tests --- .../lti/lti13-exercise-launch.component.spec.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/test/javascript/spec/component/lti/lti13-exercise-launch.component.spec.ts b/src/test/javascript/spec/component/lti/lti13-exercise-launch.component.spec.ts index d6a84e31ae72..050240b898b9 100644 --- a/src/test/javascript/spec/component/lti/lti13-exercise-launch.component.spec.ts +++ b/src/test/javascript/spec/component/lti/lti13-exercise-launch.component.spec.ts @@ -5,12 +5,16 @@ import { ActivatedRoute, ActivatedRouteSnapshot, convertToParamMap } from '@angu import { HttpClientTestingModule } from '@angular/common/http/testing'; import { HttpClient } from '@angular/common/http'; import { of, throwError } from 'rxjs'; +import { LoginService } from 'app/core/login/login.service'; +import { AccountService } from 'app/core/auth/account.service'; describe('Lti13ExerciseLaunchComponent', () => { let fixture: ComponentFixture; let comp: Lti13ExerciseLaunchComponent; let route: ActivatedRoute; let http: HttpClient; + let loginService: LoginService; + let accountService: AccountService; beforeEach(() => { route = { @@ -21,7 +25,11 @@ describe('Lti13ExerciseLaunchComponent', () => { TestBed.configureTestingModule({ imports: [ArtemisTestModule, HttpClientTestingModule], - providers: [{ provide: ActivatedRoute, useValue: route }], + providers: [ + { provide: ActivatedRoute, useValue: route }, + { provide: LoginService, useValue: loginService }, + { provide: AccountService, useValue: accountService }, + ], }) .compileComponents() .then(() => { From 17879128bd568d4a36ff01da9d6d0bc41bd909d7 Mon Sep 17 00:00:00 2001 From: Basak Akan Date: Fri, 6 Oct 2023 15:28:41 +0200 Subject: [PATCH 08/88] add custom exception --- .../exception/LtiEmailAlreadyInUseException.java | 10 ++++++++++ .../www1/artemis/security/lti/Lti13LaunchFilter.java | 6 +++--- .../artemis/service/connectors/lti/LtiService.java | 3 ++- 3 files changed, 15 insertions(+), 4 deletions(-) create mode 100644 src/main/java/de/tum/in/www1/artemis/exception/LtiEmailAlreadyInUseException.java diff --git a/src/main/java/de/tum/in/www1/artemis/exception/LtiEmailAlreadyInUseException.java b/src/main/java/de/tum/in/www1/artemis/exception/LtiEmailAlreadyInUseException.java new file mode 100644 index 000000000000..e5414b5bdc8c --- /dev/null +++ b/src/main/java/de/tum/in/www1/artemis/exception/LtiEmailAlreadyInUseException.java @@ -0,0 +1,10 @@ +package de.tum.in.www1.artemis.exception; + +import org.springframework.security.authentication.AuthenticationServiceException; + +public class LtiEmailAlreadyInUseException extends AuthenticationServiceException { + + public LtiEmailAlreadyInUseException() { + super("Email address is already in use by Artemis. Please login again to access Artemis content."); + } +} diff --git a/src/main/java/de/tum/in/www1/artemis/security/lti/Lti13LaunchFilter.java b/src/main/java/de/tum/in/www1/artemis/security/lti/Lti13LaunchFilter.java index 303862f593e3..2b590a5ea542 100644 --- a/src/main/java/de/tum/in/www1/artemis/security/lti/Lti13LaunchFilter.java +++ b/src/main/java/de/tum/in/www1/artemis/security/lti/Lti13LaunchFilter.java @@ -11,7 +11,6 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.context.annotation.Profile; -import org.springframework.security.authentication.InternalAuthenticationServiceException; import org.springframework.security.oauth2.core.OAuth2AuthenticationException; import org.springframework.security.oauth2.core.oidc.OidcIdToken; import org.springframework.security.oauth2.core.oidc.user.OidcUser; @@ -21,6 +20,7 @@ import org.springframework.web.util.UriComponentsBuilder; import de.tum.in.www1.artemis.domain.lti.Claims; +import de.tum.in.www1.artemis.exception.LtiEmailAlreadyInUseException; import de.tum.in.www1.artemis.service.connectors.lti.Lti13Service; import net.minidev.json.JSONObject; import uk.ac.ox.ctl.lti13.security.oauth2.client.lti.authentication.OidcAuthenticationToken; @@ -71,8 +71,8 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse log.error("Error during LTI 1.3 launch request: {}", ex.getMessage()); response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, "LTI 1.3 Launch failed"); } - catch (InternalAuthenticationServiceException ex) { - response.sendError(HttpServletResponse.SC_UNAUTHORIZED, ex.getMessage()); + catch (LtiEmailAlreadyInUseException ex) { + response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "LTI 1.3 user authentication failed"); } } diff --git a/src/main/java/de/tum/in/www1/artemis/service/connectors/lti/LtiService.java b/src/main/java/de/tum/in/www1/artemis/service/connectors/lti/LtiService.java index b8819ce035c8..53899b59f1bd 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/connectors/lti/LtiService.java +++ b/src/main/java/de/tum/in/www1/artemis/service/connectors/lti/LtiService.java @@ -28,6 +28,7 @@ import de.tum.in.www1.artemis.domain.Exercise; import de.tum.in.www1.artemis.domain.User; import de.tum.in.www1.artemis.exception.ArtemisAuthenticationException; +import de.tum.in.www1.artemis.exception.LtiEmailAlreadyInUseException; import de.tum.in.www1.artemis.repository.UserRepository; import de.tum.in.www1.artemis.security.ArtemisAuthenticationProvider; import de.tum.in.www1.artemis.security.Role; @@ -100,7 +101,7 @@ public void authenticateLtiUser(String email, String username, String firstName, // 2. Case: Lookup user with the LTI email address and make sure it's not in use final var usernameLookupByEmail = artemisAuthenticationProvider.getUsernameForEmail(email); if (usernameLookupByEmail.isPresent()) { - throw new InternalAuthenticationServiceException("Please login again as " + usernameLookupByEmail); + throw new LtiEmailAlreadyInUseException(); } // 3. Case: Create new user if an existing user is not required From db38dff2cd3f6126127e2f02766ff0e016987cf6 Mon Sep 17 00:00:00 2001 From: Basak Akan Date: Fri, 6 Oct 2023 16:13:51 +0200 Subject: [PATCH 09/88] refactor for teamscale findings fix --- .../artemis/exception/LtiEmailAlreadyInUseException.java | 8 ++++++-- .../in/www1/artemis/security/lti/Lti13LaunchFilter.java | 2 +- .../artemis/service/connectors/lti/Lti13Service.java | 9 +++++++-- .../in/www1/artemis/web/rest/open/PublicLtiResource.java | 2 +- .../webapp/app/lti/lti13-exercise-launch.component.ts | 2 +- 5 files changed, 16 insertions(+), 7 deletions(-) diff --git a/src/main/java/de/tum/in/www1/artemis/exception/LtiEmailAlreadyInUseException.java b/src/main/java/de/tum/in/www1/artemis/exception/LtiEmailAlreadyInUseException.java index e5414b5bdc8c..7389732aaa1c 100644 --- a/src/main/java/de/tum/in/www1/artemis/exception/LtiEmailAlreadyInUseException.java +++ b/src/main/java/de/tum/in/www1/artemis/exception/LtiEmailAlreadyInUseException.java @@ -1,8 +1,12 @@ package de.tum.in.www1.artemis.exception; -import org.springframework.security.authentication.AuthenticationServiceException; +import org.springframework.security.authentication.InternalAuthenticationServiceException; -public class LtiEmailAlreadyInUseException extends AuthenticationServiceException { +/** + * Exception thrown when an email provided during LTI authentication is already in use within Artemis. + * This is an unchecked exception and indicates that the user should re-login to access Artemis content. + */ +public class LtiEmailAlreadyInUseException extends InternalAuthenticationServiceException { public LtiEmailAlreadyInUseException() { super("Email address is already in use by Artemis. Please login again to access Artemis content."); diff --git a/src/main/java/de/tum/in/www1/artemis/security/lti/Lti13LaunchFilter.java b/src/main/java/de/tum/in/www1/artemis/security/lti/Lti13LaunchFilter.java index 2b590a5ea542..8ef3e79d1360 100644 --- a/src/main/java/de/tum/in/www1/artemis/security/lti/Lti13LaunchFilter.java +++ b/src/main/java/de/tum/in/www1/artemis/security/lti/Lti13LaunchFilter.java @@ -61,7 +61,7 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse OidcIdToken ltiIdToken = ((OidcUser) authToken.getPrincipal()).getIdToken(); // get username from client to set authentication - lti13Service.setAuthenticationFromClient(request); + lti13Service.authenticateUserFromRequestParam(request); lti13Service.performLaunch(ltiIdToken, authToken.getAuthorizedClientRegistrationId()); diff --git a/src/main/java/de/tum/in/www1/artemis/service/connectors/lti/Lti13Service.java b/src/main/java/de/tum/in/www1/artemis/service/connectors/lti/Lti13Service.java index b9610b897854..9c90d73f1bd7 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/connectors/lti/Lti13Service.java +++ b/src/main/java/de/tum/in/www1/artemis/service/connectors/lti/Lti13Service.java @@ -301,8 +301,13 @@ public void buildLtiResponse(UriComponentsBuilder uriComponentsBuilder, HttpServ ltiService.buildLtiResponse(uriComponentsBuilder, response); } - public void setAuthenticationFromClient(HttpServletRequest request) { - var username = request.getParameter("auth"); + /** + * Authenticates the user based on the provided request parameter if the user was previously authenticated in the same browser session. + * + * @param request the HTTP request containing the user authentication parameter + */ + public void authenticateUserFromRequestParam(HttpServletRequest request) { + var username = request.getParameter("authenticatedUser"); if (username != null) { var user = userRepository.findOneByLogin(username).orElseThrow(); SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(user.getLogin(), user.getPassword(), SIMPLE_USER_LIST_AUTHORITY)); diff --git a/src/main/java/de/tum/in/www1/artemis/web/rest/open/PublicLtiResource.java b/src/main/java/de/tum/in/www1/artemis/web/rest/open/PublicLtiResource.java index 4928065a358b..ae2aea89fb63 100644 --- a/src/main/java/de/tum/in/www1/artemis/web/rest/open/PublicLtiResource.java +++ b/src/main/java/de/tum/in/www1/artemis/web/rest/open/PublicLtiResource.java @@ -166,7 +166,7 @@ public void lti13LaunchRedirect(HttpServletRequest request, HttpServletResponse uriBuilder.path(LOGIN_REDIRECT_CLIENT_PATH); uriBuilder.queryParam("state", UriComponent.encode(state, UriComponent.Type.QUERY_PARAM)); uriBuilder.queryParam("id_token", UriComponent.encode(idToken, UriComponent.Type.QUERY_PARAM)); - uriBuilder.queryParam("auth", UriComponent.encode(userName, UriComponent.Type.QUERY_PARAM)); + uriBuilder.queryParam("authenticatedUser", UriComponent.encode(userName, UriComponent.Type.QUERY_PARAM)); String redirectUrl = uriBuilder.build().toString(); log.info("redirect to url: {}", redirectUrl); response.sendRedirect(redirectUrl); // Redirect using user-provided values is safe because user-provided values are used in the query parameters, not the url itself diff --git a/src/main/webapp/app/lti/lti13-exercise-launch.component.ts b/src/main/webapp/app/lti/lti13-exercise-launch.component.ts index 892fffd7cd44..f2a559e5a40f 100644 --- a/src/main/webapp/app/lti/lti13-exercise-launch.component.ts +++ b/src/main/webapp/app/lti/lti13-exercise-launch.component.ts @@ -30,7 +30,7 @@ export class Lti13ExerciseLaunchComponent implements OnInit { sendRequest(): void { const state = this.route.snapshot.queryParamMap.get('state'); const idToken = this.route.snapshot.queryParamMap.get('id_token'); - const auth = this.route.snapshot.queryParamMap.get('auth'); + const auth = this.route.snapshot.queryParamMap.get('authenticatedUser'); if (!state || !idToken) { console.error('Required parameter for LTI launch missing'); From 8fdb9c9bb6f7fa48b71318a4756c30fea6b9db1a Mon Sep 17 00:00:00 2001 From: Basak Akan Date: Sun, 8 Oct 2023 13:05:48 +0200 Subject: [PATCH 10/88] fix request param key --- .../lti/lti13-exercise-launch.component.ts | 22 +++++++++++-------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/src/main/webapp/app/lti/lti13-exercise-launch.component.ts b/src/main/webapp/app/lti/lti13-exercise-launch.component.ts index f2a559e5a40f..cb81fa721115 100644 --- a/src/main/webapp/app/lti/lti13-exercise-launch.component.ts +++ b/src/main/webapp/app/lti/lti13-exercise-launch.component.ts @@ -49,7 +49,7 @@ export class Lti13ExerciseLaunchComponent implements OnInit { } let requestBody = new HttpParams().set('state', state).set('id_token', idToken); - if (auth) requestBody = requestBody.set('auth', auth); + if (auth) requestBody = requestBody.set('authenticatedUser', auth); this.http .post('api/public/lti13/auth-login', requestBody.toString(), { @@ -69,14 +69,18 @@ export class Lti13ExerciseLaunchComponent implements OnInit { }, error: (error) => { if (error.status === 401) { - this.loginService.logout(false); - // Subscribe to the authentication state to know when the user logs in - this.accountService.getAuthenticationState().subscribe((account) => { - if (account) { - // resend request when user logs in again - this.sendRequest(); - } - }); + if (this.accountService.isAuthenticated() && this.accountService.userIdentity?.login === auth) { + this.sendRequest(); + } else { + this.loginService.logout(false); + // Subscribe to the authentication state to know when the user logs in + this.accountService.getAuthenticationState().subscribe((user) => { + if (user) { + // resend request when user logs in again + this.sendRequest(); + } + }); + } } else { window.sessionStorage.removeItem('state'); this.isLaunching = false; From 9a4fb757ebbc58743efbff414b5f43da5fb89543 Mon Sep 17 00:00:00 2001 From: Basak Akan Date: Wed, 11 Oct 2023 20:26:00 +0200 Subject: [PATCH 11/88] redirect to home page wait to login --- .../webapp/app/lti/lti13-exercise-launch.component.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/main/webapp/app/lti/lti13-exercise-launch.component.ts b/src/main/webapp/app/lti/lti13-exercise-launch.component.ts index cb81fa721115..86b934720ace 100644 --- a/src/main/webapp/app/lti/lti13-exercise-launch.component.ts +++ b/src/main/webapp/app/lti/lti13-exercise-launch.component.ts @@ -1,8 +1,8 @@ import { Component, OnInit } from '@angular/core'; import { ActivatedRoute } from '@angular/router'; import { HttpClient, HttpHeaders, HttpParams } from '@angular/common/http'; -import { LoginService } from 'app/core/login/login.service'; import { AccountService } from 'app/core/auth/account.service'; +import { AlertService } from 'app/core/util/alert.service'; @Component({ selector: 'jhi-lti-exercise-launch', @@ -14,7 +14,7 @@ export class Lti13ExerciseLaunchComponent implements OnInit { constructor( private route: ActivatedRoute, private http: HttpClient, - private loginService: LoginService, + private alertService: AlertService, private accountService: AccountService, ) { this.isLaunching = true; @@ -72,9 +72,14 @@ export class Lti13ExerciseLaunchComponent implements OnInit { if (this.accountService.isAuthenticated() && this.accountService.userIdentity?.login === auth) { this.sendRequest(); } else { - this.loginService.logout(false); // Subscribe to the authentication state to know when the user logs in this.accountService.getAuthenticationState().subscribe((user) => { + const username = this.route.snapshot.queryParamMap.get('authenticatedUser'); + if (username) { + this.alertService.success('artemisApp.lti.ltiSuccessLoginRequired', { user: username }); + this.accountService.setPrefilledUsername(username); + } + window.location.replace(''); if (user) { // resend request when user logs in again this.sendRequest(); From 6cf5b89775067f4fec80575c10398115d756c1a7 Mon Sep 17 00:00:00 2001 From: Basak Akan Date: Wed, 11 Oct 2023 22:16:18 +0200 Subject: [PATCH 12/88] refactor var name --- src/main/webapp/app/lti/lti13-exercise-launch.component.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/main/webapp/app/lti/lti13-exercise-launch.component.ts b/src/main/webapp/app/lti/lti13-exercise-launch.component.ts index 86b934720ace..dc538cf9e2fb 100644 --- a/src/main/webapp/app/lti/lti13-exercise-launch.component.ts +++ b/src/main/webapp/app/lti/lti13-exercise-launch.component.ts @@ -30,7 +30,7 @@ export class Lti13ExerciseLaunchComponent implements OnInit { sendRequest(): void { const state = this.route.snapshot.queryParamMap.get('state'); const idToken = this.route.snapshot.queryParamMap.get('id_token'); - const auth = this.route.snapshot.queryParamMap.get('authenticatedUser'); + const username = this.route.snapshot.queryParamMap.get('authenticatedUser'); if (!state || !idToken) { console.error('Required parameter for LTI launch missing'); @@ -49,7 +49,7 @@ export class Lti13ExerciseLaunchComponent implements OnInit { } let requestBody = new HttpParams().set('state', state).set('id_token', idToken); - if (auth) requestBody = requestBody.set('authenticatedUser', auth); + if (username) requestBody = requestBody.set('authenticatedUser', username); this.http .post('api/public/lti13/auth-login', requestBody.toString(), { @@ -69,7 +69,7 @@ export class Lti13ExerciseLaunchComponent implements OnInit { }, error: (error) => { if (error.status === 401) { - if (this.accountService.isAuthenticated() && this.accountService.userIdentity?.login === auth) { + if (this.accountService.isAuthenticated() && this.accountService.userIdentity?.login === username) { this.sendRequest(); } else { // Subscribe to the authentication state to know when the user logs in From 45beaaaa9290abcb97bd44e72f2f38031b8b5a0e Mon Sep 17 00:00:00 2001 From: Basak Akan Date: Wed, 11 Oct 2023 22:40:00 +0200 Subject: [PATCH 13/88] small fixes --- .../artemis/service/connectors/lti/Lti13Service.java | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/main/java/de/tum/in/www1/artemis/service/connectors/lti/Lti13Service.java b/src/main/java/de/tum/in/www1/artemis/service/connectors/lti/Lti13Service.java index 9c90d73f1bd7..c362ee1e132f 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/connectors/lti/Lti13Service.java +++ b/src/main/java/de/tum/in/www1/artemis/service/connectors/lti/Lti13Service.java @@ -309,8 +309,14 @@ public void buildLtiResponse(UriComponentsBuilder uriComponentsBuilder, HttpServ public void authenticateUserFromRequestParam(HttpServletRequest request) { var username = request.getParameter("authenticatedUser"); if (username != null) { - var user = userRepository.findOneByLogin(username).orElseThrow(); - SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(user.getLogin(), user.getPassword(), SIMPLE_USER_LIST_AUTHORITY)); + var user = userRepository.findOneByLogin(username); + if (user.isEmpty()) { + log.error("Cannot find the user for username {}", username); + } + else { + SecurityContextHolder.getContext() + .setAuthentication(new UsernamePasswordAuthenticationToken(user.get().getLogin(), user.get().getPassword(), SIMPLE_USER_LIST_AUTHORITY)); + } } } } From e21156ed427972994d467cd96987c02258d1e378 Mon Sep 17 00:00:00 2001 From: Basak Akan Date: Thu, 12 Oct 2023 20:37:28 +0200 Subject: [PATCH 14/88] fix redirection after login --- .../security/lti/Lti13LaunchFilter.java | 4 ++- .../service/connectors/lti/Lti13Service.java | 19 ++++++++++--- .../service/connectors/lti/LtiService.java | 2 +- .../lti/lti13-exercise-launch.component.ts | 27 ++++++++----------- .../artemis/connectors/Lti13ServiceTest.java | 6 ++++- 5 files changed, 35 insertions(+), 23 deletions(-) diff --git a/src/main/java/de/tum/in/www1/artemis/security/lti/Lti13LaunchFilter.java b/src/main/java/de/tum/in/www1/artemis/security/lti/Lti13LaunchFilter.java index 8ef3e79d1360..4f1d01f997c9 100644 --- a/src/main/java/de/tum/in/www1/artemis/security/lti/Lti13LaunchFilter.java +++ b/src/main/java/de/tum/in/www1/artemis/security/lti/Lti13LaunchFilter.java @@ -54,12 +54,13 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse filterChain.doFilter(request, response); return; } - + String targetLink = ""; try { OidcAuthenticationToken authToken = finishOidcFlow(request, response); OidcIdToken ltiIdToken = ((OidcUser) authToken.getPrincipal()).getIdToken(); + targetLink = ltiIdToken.getClaim(Claims.TARGET_LINK_URI).toString(); // get username from client to set authentication lti13Service.authenticateUserFromRequestParam(request); @@ -72,6 +73,7 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, "LTI 1.3 Launch failed"); } catch (LtiEmailAlreadyInUseException ex) { + response.setHeader("TargetLinkUri", targetLink); response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "LTI 1.3 user authentication failed"); } } diff --git a/src/main/java/de/tum/in/www1/artemis/service/connectors/lti/Lti13Service.java b/src/main/java/de/tum/in/www1/artemis/service/connectors/lti/Lti13Service.java index c362ee1e132f..1ebe31dd2114 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/connectors/lti/Lti13Service.java +++ b/src/main/java/de/tum/in/www1/artemis/service/connectors/lti/Lti13Service.java @@ -35,6 +35,7 @@ import de.tum.in.www1.artemis.domain.lti.*; import de.tum.in.www1.artemis.domain.participation.StudentParticipation; import de.tum.in.www1.artemis.repository.*; +import de.tum.in.www1.artemis.security.ArtemisAuthenticationProvider; import de.tum.in.www1.artemis.security.lti.Lti13TokenRetriever; import de.tum.in.www1.artemis.service.OnlineCourseConfigurationService; import de.tum.in.www1.artemis.web.rest.errors.BadRequestAlertException; @@ -64,11 +65,13 @@ public class Lti13Service { private final OnlineCourseConfigurationService onlineCourseConfigurationService; + private final ArtemisAuthenticationProvider artemisAuthenticationProvider; + private final RestTemplate restTemplate; public Lti13Service(UserRepository userRepository, ExerciseRepository exerciseRepository, CourseRepository courseRepository, Lti13ResourceLaunchRepository launchRepository, LtiService ltiService, ResultRepository resultRepository, Lti13TokenRetriever tokenRetriever, OnlineCourseConfigurationService onlineCourseConfigurationService, - RestTemplate restTemplate) { + RestTemplate restTemplate, ArtemisAuthenticationProvider artemisAuthenticationProvider) { this.userRepository = userRepository; this.exerciseRepository = exerciseRepository; this.courseRepository = courseRepository; @@ -78,6 +81,7 @@ public Lti13Service(UserRepository userRepository, ExerciseRepository exerciseRe this.tokenRetriever = tokenRetriever; this.onlineCourseConfigurationService = onlineCourseConfigurationService; this.restTemplate = restTemplate; + this.artemisAuthenticationProvider = artemisAuthenticationProvider; } /** @@ -106,14 +110,21 @@ public void performLaunch(OidcIdToken ltiIdToken, String clientRegistrationId) { throw new BadRequestAlertException("LTI is not configured for this course", "LTI", "ltiNotConfigured"); } - ltiService.authenticateLtiUser(ltiIdToken.getEmail(), createUsernameFromLaunchRequest(ltiIdToken, onlineCourseConfiguration), ltiIdToken.getGivenName(), - ltiIdToken.getFamilyName(), onlineCourseConfiguration.isRequireExistingUser()); - User user = userRepository.getUserWithGroupsAndAuthorities(); + String username = createUsernameFromLaunchRequest(ltiIdToken, onlineCourseConfiguration); + if (!onlineCourseConfiguration.isRequireExistingUser() && !artemisAuthenticationProvider.getUsernameForEmail(ltiIdToken.getEmail()).isPresent()) { + SecurityContextHolder.getContext().setAuthentication(ltiService.createNewUserFromLaunchRequest(ltiIdToken.getEmail(), + createUsernameFromLaunchRequest(ltiIdToken, onlineCourseConfiguration), ltiIdToken.getGivenName(), ltiIdToken.getFamilyName())); + } + + User user = userRepository.findOneWithGroupsAndAuthoritiesByLogin(username).orElseThrow(); ltiService.onSuccessfulLtiAuthentication(user, targetExercise.get()); Lti13LaunchRequest launchRequest = launchRequestFrom(ltiIdToken, clientRegistrationId); createOrUpdateResourceLaunch(launchRequest, user, targetExercise.get()); + + ltiService.authenticateLtiUser(ltiIdToken.getEmail(), createUsernameFromLaunchRequest(ltiIdToken, onlineCourseConfiguration), ltiIdToken.getGivenName(), + ltiIdToken.getFamilyName(), onlineCourseConfiguration.isRequireExistingUser()); } /** diff --git a/src/main/java/de/tum/in/www1/artemis/service/connectors/lti/LtiService.java b/src/main/java/de/tum/in/www1/artemis/service/connectors/lti/LtiService.java index 53899b59f1bd..024185a446dd 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/connectors/lti/LtiService.java +++ b/src/main/java/de/tum/in/www1/artemis/service/connectors/lti/LtiService.java @@ -114,7 +114,7 @@ public void authenticateLtiUser(String email, String username, String firstName, } @NotNull - private Authentication createNewUserFromLaunchRequest(String email, String username, String firstName, String lastName) { + protected Authentication createNewUserFromLaunchRequest(String email, String username, String firstName, String lastName) { final var user = userRepository.findOneByLogin(username).orElseGet(() -> { final User newUser; final var groups = new HashSet(); diff --git a/src/main/webapp/app/lti/lti13-exercise-launch.component.ts b/src/main/webapp/app/lti/lti13-exercise-launch.component.ts index dc538cf9e2fb..16c32a0e7ef7 100644 --- a/src/main/webapp/app/lti/lti13-exercise-launch.component.ts +++ b/src/main/webapp/app/lti/lti13-exercise-launch.component.ts @@ -1,8 +1,7 @@ import { Component, OnInit } from '@angular/core'; -import { ActivatedRoute } from '@angular/router'; +import { ActivatedRoute, Router } from '@angular/router'; import { HttpClient, HttpHeaders, HttpParams } from '@angular/common/http'; import { AccountService } from 'app/core/auth/account.service'; -import { AlertService } from 'app/core/util/alert.service'; @Component({ selector: 'jhi-lti-exercise-launch', @@ -14,8 +13,8 @@ export class Lti13ExerciseLaunchComponent implements OnInit { constructor( private route: ActivatedRoute, private http: HttpClient, - private alertService: AlertService, private accountService: AccountService, + private router: Router, ) { this.isLaunching = true; } @@ -69,21 +68,17 @@ export class Lti13ExerciseLaunchComponent implements OnInit { }, error: (error) => { if (error.status === 401) { - if (this.accountService.isAuthenticated() && this.accountService.userIdentity?.login === username) { + if (this.accountService.isAuthenticated() && this.accountService.userIdentity?.login !== username) { this.sendRequest(); } else { - // Subscribe to the authentication state to know when the user logs in - this.accountService.getAuthenticationState().subscribe((user) => { - const username = this.route.snapshot.queryParamMap.get('authenticatedUser'); - if (username) { - this.alertService.success('artemisApp.lti.ltiSuccessLoginRequired', { user: username }); - this.accountService.setPrefilledUsername(username); - } - window.location.replace(''); - if (user) { - // resend request when user logs in again - this.sendRequest(); - } + // Redirect the user to the login page + this.router.navigate(['/']).then(() => { + // After navigating to the login page, set up a listener for when the user logs in + this.accountService.getAuthenticationState().subscribe((user) => { + if (user) { + window.location.replace(error.headers.get('TargetLinkUri').toString()); + } + }); }); } } else { diff --git a/src/test/java/de/tum/in/www1/artemis/connectors/Lti13ServiceTest.java b/src/test/java/de/tum/in/www1/artemis/connectors/Lti13ServiceTest.java index 4a678b367840..9d112ce081b8 100644 --- a/src/test/java/de/tum/in/www1/artemis/connectors/Lti13ServiceTest.java +++ b/src/test/java/de/tum/in/www1/artemis/connectors/Lti13ServiceTest.java @@ -34,6 +34,7 @@ import de.tum.in.www1.artemis.domain.lti.Scopes; import de.tum.in.www1.artemis.domain.participation.StudentParticipation; import de.tum.in.www1.artemis.repository.*; +import de.tum.in.www1.artemis.security.ArtemisAuthenticationProvider; import de.tum.in.www1.artemis.security.lti.Lti13TokenRetriever; import de.tum.in.www1.artemis.service.OnlineCourseConfigurationService; import de.tum.in.www1.artemis.service.connectors.lti.Lti13Service; @@ -73,6 +74,9 @@ class Lti13ServiceTest { @Mock private RestTemplate restTemplate; + @Mock + private ArtemisAuthenticationProvider artemisAuthenticationProvider; + private OidcIdToken oidcIdToken; private String clientRegistrationId; @@ -85,7 +89,7 @@ class Lti13ServiceTest { void init() { closeable = MockitoAnnotations.openMocks(this); lti13Service = new Lti13Service(userRepository, exerciseRepository, courseRepository, launchRepository, ltiService, resultRepository, tokenRetriever, - onlineCourseConfigurationService, restTemplate); + onlineCourseConfigurationService, restTemplate, artemisAuthenticationProvider); clientRegistrationId = "clientId"; onlineCourseConfiguration = new OnlineCourseConfiguration(); onlineCourseConfiguration.setUserPrefix("prefix"); From 0ad1e58bcaff3a6310c6b728bcb508fbc9f25a75 Mon Sep 17 00:00:00 2001 From: Basak Akan Date: Thu, 12 Oct 2023 23:06:23 +0200 Subject: [PATCH 15/88] add check for if user already logged in --- .../lti/lti13-exercise-launch.component.ts | 29 +++++++++++-------- 1 file changed, 17 insertions(+), 12 deletions(-) diff --git a/src/main/webapp/app/lti/lti13-exercise-launch.component.ts b/src/main/webapp/app/lti/lti13-exercise-launch.component.ts index 16c32a0e7ef7..8026a2c81a45 100644 --- a/src/main/webapp/app/lti/lti13-exercise-launch.component.ts +++ b/src/main/webapp/app/lti/lti13-exercise-launch.component.ts @@ -68,19 +68,24 @@ export class Lti13ExerciseLaunchComponent implements OnInit { }, error: (error) => { if (error.status === 401) { - if (this.accountService.isAuthenticated() && this.accountService.userIdentity?.login !== username) { - this.sendRequest(); - } else { - // Redirect the user to the login page - this.router.navigate(['/']).then(() => { - // After navigating to the login page, set up a listener for when the user logs in - this.accountService.getAuthenticationState().subscribe((user) => { - if (user) { - window.location.replace(error.headers.get('TargetLinkUri').toString()); - } + this.accountService.identity().then((user) => { + if (user) { + if (user) { + // resend request since user is already logged in + window.location.replace(error.headers.get('TargetLinkUri').toString()); + } + } else { + // Redirect the user to the login page + this.router.navigate(['/']).then(() => { + // After navigating to the login page, set up a listener for when the user logs in + this.accountService.getAuthenticationState().subscribe((user) => { + if (user) { + window.location.replace(error.headers.get('TargetLinkUri').toString()); + } + }); }); - }); - } + } + }); } else { window.sessionStorage.removeItem('state'); this.isLaunching = false; From 41ef03afdf7a6bcd34449e795877fe93a40ebeac Mon Sep 17 00:00:00 2001 From: Basak Akan Date: Thu, 12 Oct 2023 23:27:36 +0200 Subject: [PATCH 16/88] revert unnecessary request param username --- .../security/lti/Lti13LaunchFilter.java | 2 -- .../service/connectors/lti/Lti13Service.java | 23 ------------------- .../web/rest/open/PublicLtiResource.java | 1 - .../lti/lti13-exercise-launch.component.ts | 4 +--- 4 files changed, 1 insertion(+), 29 deletions(-) diff --git a/src/main/java/de/tum/in/www1/artemis/security/lti/Lti13LaunchFilter.java b/src/main/java/de/tum/in/www1/artemis/security/lti/Lti13LaunchFilter.java index 4f1d01f997c9..96193c3ae36c 100644 --- a/src/main/java/de/tum/in/www1/artemis/security/lti/Lti13LaunchFilter.java +++ b/src/main/java/de/tum/in/www1/artemis/security/lti/Lti13LaunchFilter.java @@ -61,8 +61,6 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse OidcIdToken ltiIdToken = ((OidcUser) authToken.getPrincipal()).getIdToken(); targetLink = ltiIdToken.getClaim(Claims.TARGET_LINK_URI).toString(); - // get username from client to set authentication - lti13Service.authenticateUserFromRequestParam(request); lti13Service.performLaunch(ltiIdToken, authToken.getAuthorizedClientRegistrationId()); diff --git a/src/main/java/de/tum/in/www1/artemis/service/connectors/lti/Lti13Service.java b/src/main/java/de/tum/in/www1/artemis/service/connectors/lti/Lti13Service.java index 1ebe31dd2114..f7d9b428f465 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/connectors/lti/Lti13Service.java +++ b/src/main/java/de/tum/in/www1/artemis/service/connectors/lti/Lti13Service.java @@ -1,7 +1,5 @@ package de.tum.in.www1.artemis.service.connectors.lti; -import static de.tum.in.www1.artemis.service.connectors.lti.LtiService.SIMPLE_USER_LIST_AUTHORITY; - import java.net.MalformedURLException; import java.net.URL; import java.util.Collection; @@ -9,7 +7,6 @@ import java.util.Optional; import java.util.stream.Collectors; -import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import javax.validation.constraints.NotNull; @@ -20,7 +17,6 @@ import org.springframework.http.HttpEntity; import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; -import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.oauth2.client.registration.ClientRegistration; import org.springframework.security.oauth2.core.oidc.OidcIdToken; @@ -311,23 +307,4 @@ private void createOrUpdateResourceLaunch(Lti13LaunchRequest launchRequest, User public void buildLtiResponse(UriComponentsBuilder uriComponentsBuilder, HttpServletResponse response) { ltiService.buildLtiResponse(uriComponentsBuilder, response); } - - /** - * Authenticates the user based on the provided request parameter if the user was previously authenticated in the same browser session. - * - * @param request the HTTP request containing the user authentication parameter - */ - public void authenticateUserFromRequestParam(HttpServletRequest request) { - var username = request.getParameter("authenticatedUser"); - if (username != null) { - var user = userRepository.findOneByLogin(username); - if (user.isEmpty()) { - log.error("Cannot find the user for username {}", username); - } - else { - SecurityContextHolder.getContext() - .setAuthentication(new UsernamePasswordAuthenticationToken(user.get().getLogin(), user.get().getPassword(), SIMPLE_USER_LIST_AUTHORITY)); - } - } - } } diff --git a/src/main/java/de/tum/in/www1/artemis/web/rest/open/PublicLtiResource.java b/src/main/java/de/tum/in/www1/artemis/web/rest/open/PublicLtiResource.java index ae2aea89fb63..6e7a47462dae 100644 --- a/src/main/java/de/tum/in/www1/artemis/web/rest/open/PublicLtiResource.java +++ b/src/main/java/de/tum/in/www1/artemis/web/rest/open/PublicLtiResource.java @@ -166,7 +166,6 @@ public void lti13LaunchRedirect(HttpServletRequest request, HttpServletResponse uriBuilder.path(LOGIN_REDIRECT_CLIENT_PATH); uriBuilder.queryParam("state", UriComponent.encode(state, UriComponent.Type.QUERY_PARAM)); uriBuilder.queryParam("id_token", UriComponent.encode(idToken, UriComponent.Type.QUERY_PARAM)); - uriBuilder.queryParam("authenticatedUser", UriComponent.encode(userName, UriComponent.Type.QUERY_PARAM)); String redirectUrl = uriBuilder.build().toString(); log.info("redirect to url: {}", redirectUrl); response.sendRedirect(redirectUrl); // Redirect using user-provided values is safe because user-provided values are used in the query parameters, not the url itself diff --git a/src/main/webapp/app/lti/lti13-exercise-launch.component.ts b/src/main/webapp/app/lti/lti13-exercise-launch.component.ts index 8026a2c81a45..a4fb7fbb558b 100644 --- a/src/main/webapp/app/lti/lti13-exercise-launch.component.ts +++ b/src/main/webapp/app/lti/lti13-exercise-launch.component.ts @@ -29,7 +29,6 @@ export class Lti13ExerciseLaunchComponent implements OnInit { sendRequest(): void { const state = this.route.snapshot.queryParamMap.get('state'); const idToken = this.route.snapshot.queryParamMap.get('id_token'); - const username = this.route.snapshot.queryParamMap.get('authenticatedUser'); if (!state || !idToken) { console.error('Required parameter for LTI launch missing'); @@ -47,8 +46,7 @@ export class Lti13ExerciseLaunchComponent implements OnInit { return; } - let requestBody = new HttpParams().set('state', state).set('id_token', idToken); - if (username) requestBody = requestBody.set('authenticatedUser', username); + const requestBody = new HttpParams().set('state', state).set('id_token', idToken); this.http .post('api/public/lti13/auth-login', requestBody.toString(), { From e2ec1bc8068d944d41c4de358fe7627db923a23a Mon Sep 17 00:00:00 2001 From: Basak Akan Date: Thu, 12 Oct 2023 23:32:50 +0200 Subject: [PATCH 17/88] fix team-scale findings --- .../artemis/exception/LtiEmailAlreadyInUseException.java | 3 +++ .../in/www1/artemis/web/rest/open/PublicLtiResource.java | 7 ------- 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/src/main/java/de/tum/in/www1/artemis/exception/LtiEmailAlreadyInUseException.java b/src/main/java/de/tum/in/www1/artemis/exception/LtiEmailAlreadyInUseException.java index 7389732aaa1c..c56f83b66bf0 100644 --- a/src/main/java/de/tum/in/www1/artemis/exception/LtiEmailAlreadyInUseException.java +++ b/src/main/java/de/tum/in/www1/artemis/exception/LtiEmailAlreadyInUseException.java @@ -8,6 +8,9 @@ */ public class LtiEmailAlreadyInUseException extends InternalAuthenticationServiceException { + /** + * Constructs a new LtiEmailAlreadyInUseException with a default message. + */ public LtiEmailAlreadyInUseException() { super("Email address is already in use by Artemis. Please login again to access Artemis content."); } diff --git a/src/main/java/de/tum/in/www1/artemis/web/rest/open/PublicLtiResource.java b/src/main/java/de/tum/in/www1/artemis/web/rest/open/PublicLtiResource.java index 6e7a47462dae..0cd72162c321 100644 --- a/src/main/java/de/tum/in/www1/artemis/web/rest/open/PublicLtiResource.java +++ b/src/main/java/de/tum/in/www1/artemis/web/rest/open/PublicLtiResource.java @@ -12,7 +12,6 @@ import org.springframework.context.annotation.Profile; import org.springframework.http.HttpStatus; import org.springframework.security.authentication.InternalAuthenticationServiceException; -import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.web.bind.annotation.ModelAttribute; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; @@ -25,7 +24,6 @@ import de.tum.in.www1.artemis.domain.OnlineCourseConfiguration; import de.tum.in.www1.artemis.repository.CourseRepository; import de.tum.in.www1.artemis.repository.ExerciseRepository; -import de.tum.in.www1.artemis.security.SecurityUtils; import de.tum.in.www1.artemis.security.annotations.EnforceNothing; import de.tum.in.www1.artemis.service.connectors.lti.Lti10Service; import de.tum.in.www1.artemis.web.rest.dto.LtiLaunchRequestDTO; @@ -157,11 +155,6 @@ public void lti13LaunchRedirect(HttpServletRequest request, HttpServletResponse return; } - String userName = ""; - if (SecurityUtils.isAuthenticated()) { - userName = SecurityContextHolder.getContext().getAuthentication().getName(); - } - UriComponentsBuilder uriBuilder = buildRedirect(request); uriBuilder.path(LOGIN_REDIRECT_CLIENT_PATH); uriBuilder.queryParam("state", UriComponent.encode(state, UriComponent.Type.QUERY_PARAM)); From d6a45c6009e06e5b7858873254c1e1c208aa765b Mon Sep 17 00:00:00 2001 From: Basak Akan Date: Thu, 12 Oct 2023 23:44:11 +0200 Subject: [PATCH 18/88] fix server tests --- .../tum/in/www1/artemis/connectors/Lti13ServiceTest.java | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/test/java/de/tum/in/www1/artemis/connectors/Lti13ServiceTest.java b/src/test/java/de/tum/in/www1/artemis/connectors/Lti13ServiceTest.java index 9d112ce081b8..c305c8aa804f 100644 --- a/src/test/java/de/tum/in/www1/artemis/connectors/Lti13ServiceTest.java +++ b/src/test/java/de/tum/in/www1/artemis/connectors/Lti13ServiceTest.java @@ -127,8 +127,8 @@ void performLaunch_exerciseFound() { when(oidcIdToken.getClaim(Claims.RESOURCE_LINK)).thenReturn(jsonObject); when(oidcIdToken.getClaim(Claims.TARGET_LINK_URI)).thenReturn("https://some-artemis-domain.org/courses/" + courseId + "/exercises/" + exerciseId); - User user = new User(); - doReturn(user).when(userRepository).getUserWithGroupsAndAuthorities(); + Optional user = Optional.of(new User()); + doReturn(user).when(userRepository).findOneWithGroupsAndAuthoritiesByLogin(any()); doNothing().when(ltiService).authenticateLtiUser(any(), any(), any(), any(), anyBoolean()); doNothing().when(ltiService).onSuccessfulLtiAuthentication(any(), any()); @@ -155,8 +155,8 @@ void performLaunch_invalidToken() { when(oidcIdToken.getEmail()).thenReturn("testuser@email.com"); when(oidcIdToken.getClaim(Claims.TARGET_LINK_URI)).thenReturn("https://some-artemis-domain.org/courses/" + courseId + "/exercises/" + exerciseId); - User user = new User(); - doReturn(user).when(userRepository).getUserWithGroupsAndAuthorities(); + Optional user = Optional.of(new User()); + doReturn(user).when(userRepository).findOneWithGroupsAndAuthoritiesByLogin(any()); doNothing().when(ltiService).authenticateLtiUser(any(), any(), any(), any(), anyBoolean()); doNothing().when(ltiService).onSuccessfulLtiAuthentication(any(), any()); From 0fd08fcd105004c6f85d19e4cfa6d728fa69e9bb Mon Sep 17 00:00:00 2001 From: Basak Akan Date: Fri, 13 Oct 2023 00:51:03 +0200 Subject: [PATCH 19/88] fix for already existing users --- .../www1/artemis/service/connectors/lti/Lti13Service.java | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/main/java/de/tum/in/www1/artemis/service/connectors/lti/Lti13Service.java b/src/main/java/de/tum/in/www1/artemis/service/connectors/lti/Lti13Service.java index f7d9b428f465..9360d41eeb0b 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/connectors/lti/Lti13Service.java +++ b/src/main/java/de/tum/in/www1/artemis/service/connectors/lti/Lti13Service.java @@ -106,15 +106,17 @@ public void performLaunch(OidcIdToken ltiIdToken, String clientRegistrationId) { throw new BadRequestAlertException("LTI is not configured for this course", "LTI", "ltiNotConfigured"); } - String username = createUsernameFromLaunchRequest(ltiIdToken, onlineCourseConfiguration); - if (!onlineCourseConfiguration.isRequireExistingUser() && !artemisAuthenticationProvider.getUsernameForEmail(ltiIdToken.getEmail()).isPresent()) { + Optional optionalUsername = artemisAuthenticationProvider.getUsernameForEmail(ltiIdToken.getEmail()); + + if (!onlineCourseConfiguration.isRequireExistingUser() && optionalUsername.isEmpty()) { SecurityContextHolder.getContext().setAuthentication(ltiService.createNewUserFromLaunchRequest(ltiIdToken.getEmail(), createUsernameFromLaunchRequest(ltiIdToken, onlineCourseConfiguration), ltiIdToken.getGivenName(), ltiIdToken.getFamilyName())); + } + String username = optionalUsername.orElseGet(() -> createUsernameFromLaunchRequest(ltiIdToken, onlineCourseConfiguration)); User user = userRepository.findOneWithGroupsAndAuthoritiesByLogin(username).orElseThrow(); ltiService.onSuccessfulLtiAuthentication(user, targetExercise.get()); - Lti13LaunchRequest launchRequest = launchRequestFrom(ltiIdToken, clientRegistrationId); createOrUpdateResourceLaunch(launchRequest, user, targetExercise.get()); From 120782f62f2f41fb2c16dbd76ffc31c5e8e951da Mon Sep 17 00:00:00 2001 From: Basak Akan Date: Fri, 13 Oct 2023 09:02:02 +0200 Subject: [PATCH 20/88] fix codacy issues --- .../artemis/connectors/Lti13ServiceTest.java | 27 +++++++++---------- 1 file changed, 12 insertions(+), 15 deletions(-) diff --git a/src/test/java/de/tum/in/www1/artemis/connectors/Lti13ServiceTest.java b/src/test/java/de/tum/in/www1/artemis/connectors/Lti13ServiceTest.java index c305c8aa804f..4f43b18b84c5 100644 --- a/src/test/java/de/tum/in/www1/artemis/connectors/Lti13ServiceTest.java +++ b/src/test/java/de/tum/in/www1/artemis/connectors/Lti13ServiceTest.java @@ -118,19 +118,13 @@ void performLaunch_exerciseFound() { doReturn(Optional.of(exercise)).when(exerciseRepository).findById(exerciseId); doReturn(course).when(courseRepository).findByIdWithEagerOnlineCourseConfigurationElseThrow(courseId); - when(oidcIdToken.getEmail()).thenReturn("testuser@email.com"); when(oidcIdToken.getClaim("sub")).thenReturn("1"); when(oidcIdToken.getClaim("iss")).thenReturn("http://otherDomain.com"); when(oidcIdToken.getClaim(Claims.LTI_DEPLOYMENT_ID)).thenReturn("1"); JSONObject jsonObject = new JSONObject(); jsonObject.put("id", "resourceLinkUrl"); when(oidcIdToken.getClaim(Claims.RESOURCE_LINK)).thenReturn(jsonObject); - when(oidcIdToken.getClaim(Claims.TARGET_LINK_URI)).thenReturn("https://some-artemis-domain.org/courses/" + courseId + "/exercises/" + exerciseId); - - Optional user = Optional.of(new User()); - doReturn(user).when(userRepository).findOneWithGroupsAndAuthoritiesByLogin(any()); - doNothing().when(ltiService).authenticateLtiUser(any(), any(), any(), any(), anyBoolean()); - doNothing().when(ltiService).onSuccessfulLtiAuthentication(any(), any()); + prepareForPerformLaunch(courseId, exerciseId); lti13Service.performLaunch(oidcIdToken, clientRegistrationId); @@ -151,14 +145,7 @@ void performLaunch_invalidToken() { exercise.setCourse(course); doReturn(Optional.of(exercise)).when(exerciseRepository).findById(exerciseId); doReturn(course).when(courseRepository).findByIdWithEagerOnlineCourseConfigurationElseThrow(courseId); - - when(oidcIdToken.getEmail()).thenReturn("testuser@email.com"); - when(oidcIdToken.getClaim(Claims.TARGET_LINK_URI)).thenReturn("https://some-artemis-domain.org/courses/" + courseId + "/exercises/" + exerciseId); - - Optional user = Optional.of(new User()); - doReturn(user).when(userRepository).findOneWithGroupsAndAuthoritiesByLogin(any()); - doNothing().when(ltiService).authenticateLtiUser(any(), any(), any(), any(), anyBoolean()); - doNothing().when(ltiService).onSuccessfulLtiAuthentication(any(), any()); + prepareForPerformLaunch(courseId, exerciseId); assertThatIllegalArgumentException().isThrownBy(() -> lti13Service.performLaunch(oidcIdToken, clientRegistrationId)); @@ -517,4 +504,14 @@ private State getValidStateForNewResult(Result result) { private record State(LtiResourceLaunch ltiResourceLaunch, Exercise exercise, User user, StudentParticipation participation, Result result, ClientRegistration clientRegistration) { } + + private void prepareForPerformLaunch(long courseId, long exerciseId) { + when(oidcIdToken.getEmail()).thenReturn("testuser@email.com"); + when(oidcIdToken.getClaim(Claims.TARGET_LINK_URI)).thenReturn("https://some-artemis-domain.org/courses/" + courseId + "/exercises/" + exerciseId); + + Optional user = Optional.of(new User()); + doReturn(user).when(userRepository).findOneWithGroupsAndAuthoritiesByLogin(any()); + doNothing().when(ltiService).authenticateLtiUser(any(), any(), any(), any(), anyBoolean()); + doNothing().when(ltiService).onSuccessfulLtiAuthentication(any(), any()); + } } From 0523df873e2de28ce057664b16636b24eb79242d Mon Sep 17 00:00:00 2001 From: Basak Akan Date: Mon, 16 Oct 2023 15:11:49 +0200 Subject: [PATCH 21/88] first implementation for server-side --- .../in/www1/artemis/domain/lti/Claims.java | 2 + .../security/lti/Lti13LaunchFilter.java | 33 +++++++- .../security/lti/Lti13TokenRetriever.java | 31 ++++++++ .../service/connectors/lti/Lti13Service.java | 36 ++++++++- .../connectors/lti/LtiDeepLinkingService.java | 75 +++++++++++++++++++ src/main/webapp/app/lti/lti.module.ts | 8 +- src/main/webapp/app/lti/lti.route.ts | 2 +- .../app/lti/lti13-deep-linking.component.html | 6 ++ .../app/lti/lti13-deep-linking.component.ts | 46 +++++------- .../artemis/connectors/Lti13ServiceTest.java | 6 +- 10 files changed, 207 insertions(+), 38 deletions(-) create mode 100644 src/main/java/de/tum/in/www1/artemis/service/connectors/lti/LtiDeepLinkingService.java diff --git a/src/main/java/de/tum/in/www1/artemis/domain/lti/Claims.java b/src/main/java/de/tum/in/www1/artemis/domain/lti/Claims.java index 1228b5a55686..634526b411fe 100644 --- a/src/main/java/de/tum/in/www1/artemis/domain/lti/Claims.java +++ b/src/main/java/de/tum/in/www1/artemis/domain/lti/Claims.java @@ -3,4 +3,6 @@ public class Claims extends uk.ac.ox.ctl.lti13.lti.Claims { public static final String AGS_CLAIM = "https://purl.imsglobal.org/spec/lti-ags/claim/endpoint"; + + public static final String MSG = "https://purl.imsglobal.org/spec/lti-dl/claim/msg"; } diff --git a/src/main/java/de/tum/in/www1/artemis/security/lti/Lti13LaunchFilter.java b/src/main/java/de/tum/in/www1/artemis/security/lti/Lti13LaunchFilter.java index 24988609396d..2516ead605d2 100644 --- a/src/main/java/de/tum/in/www1/artemis/security/lti/Lti13LaunchFilter.java +++ b/src/main/java/de/tum/in/www1/artemis/security/lti/Lti13LaunchFilter.java @@ -19,6 +19,8 @@ import org.springframework.web.filter.OncePerRequestFilter; import org.springframework.web.util.UriComponentsBuilder; +import com.nimbusds.jose.JOSEException; + import de.tum.in.www1.artemis.domain.lti.Claims; import de.tum.in.www1.artemis.service.connectors.lti.Lti13Service; import net.minidev.json.JSONObject; @@ -60,10 +62,10 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse OidcIdToken ltiIdToken = ((OidcUser) authToken.getPrincipal()).getIdToken(); // here we need to check if this is a deep-linking request or a launch request - if (ltiIdToken.getClaim(Claims.MESSAGE_TYPE) == "LtiDeepLinkingRequest") { + if (ltiIdToken.getClaim(Claims.MESSAGE_TYPE).equals("LtiDeepLinkingRequest")) { + + lti13DeepLinking(ltiIdToken, response, authToken.getAuthorizedClientRegistrationId()); - writeResponse(ltiIdToken.getClaim(Claims.TARGET_LINK_URI), response); - // ltiIdToken.getClaim(Claims.DEEP_LINKING_SETTINGS) -- needed } else { lti13Service.performLaunch(ltiIdToken, authToken.getAuthorizedClientRegistrationId()); @@ -76,6 +78,9 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse log.error("Error during LTI 1.3 launch request: {}", ex.getMessage()); response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, "LTI 1.3 Launch failed"); } + catch (JOSEException e) { + throw new RuntimeException(e); + } } private OidcAuthenticationToken finishOidcFlow(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { @@ -108,4 +113,26 @@ private void writeResponse(String targetLinkUri, HttpServletResponse response) t writer.print(json); writer.flush(); } + + private void lti13DeepLinking(OidcIdToken ltiIdToken, HttpServletResponse response, String clientRegistrationId) throws IOException, JOSEException { + + lti13Service.authenticateLtiUser(ltiIdToken); + + PrintWriter writer = response.getWriter(); + UriComponentsBuilder uriBuilder = UriComponentsBuilder.fromUriString("http://localhost:9000/lti/deep-linking"); + lti13Service.buildLtiDeepLinkResponse(ltiIdToken, uriBuilder, clientRegistrationId); + + response.setContentType("application/json"); + response.setCharacterEncoding("UTF-8"); + + JSONObject json = new JSONObject(); + json.put("targetLinkUri", uriBuilder.build().toUriString()); + + response.setContentType("application/json"); + response.setCharacterEncoding("UTF-8"); + + writer.print(json); + writer.flush(); + + } } diff --git a/src/main/java/de/tum/in/www1/artemis/security/lti/Lti13TokenRetriever.java b/src/main/java/de/tum/in/www1/artemis/security/lti/Lti13TokenRetriever.java index 0d015b913ea1..107b611d8576 100644 --- a/src/main/java/de/tum/in/www1/artemis/security/lti/Lti13TokenRetriever.java +++ b/src/main/java/de/tum/in/www1/artemis/security/lti/Lti13TokenRetriever.java @@ -87,6 +87,37 @@ public String getToken(ClientRegistration clientRegistration, String... scopes) } } + public String createDeepLinkingJWT(String clientRegistrationId, Map customClaims) { + JWK jwk = oAuth2JWKSService.getJWK(clientRegistrationId); + + if (jwk == null) { + throw new IllegalArgumentException("Failed to get JWK for client registration: " + clientRegistrationId); + } + + try { + KeyPair keyPair = jwk.toRSAKey().toKeyPair(); + RSASSASigner signer = new RSASSASigner(keyPair.getPrivate()); + + var claimSetBuilder = new JWTClaimsSet.Builder(); + for (Map.Entry entry : customClaims.entrySet()) { + claimSetBuilder.claim(entry.getKey(), entry.getValue()); + } + + JWTClaimsSet claimsSet = claimSetBuilder.issueTime(Date.from(Instant.now())).expirationTime(Date.from(Instant.now().plusSeconds(JWT_LIFETIME))).build(); + + JWSHeader jwt = new JWSHeader.Builder(JWSAlgorithm.RS256).type(JOSEObjectType.JWT).keyID(jwk.getKeyID()).build(); + SignedJWT signedJWT = new SignedJWT(jwt, claimsSet); + signedJWT.sign(signer); + + log.debug("Created signed token: {}", signedJWT.serialize()); + return signedJWT.serialize(); + } + catch (JOSEException e) { + log.error("Could not create keypair for clientRegistrationId {}", clientRegistrationId); + return null; + } + } + private SignedJWT createJWT(ClientRegistration clientRegistration) { JWK jwk = oAuth2JWKSService.getJWK(clientRegistration.getRegistrationId()); diff --git a/src/main/java/de/tum/in/www1/artemis/service/connectors/lti/Lti13Service.java b/src/main/java/de/tum/in/www1/artemis/service/connectors/lti/Lti13Service.java index 7697abf862d6..e59a54bb06ae 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/connectors/lti/Lti13Service.java +++ b/src/main/java/de/tum/in/www1/artemis/service/connectors/lti/Lti13Service.java @@ -2,14 +2,13 @@ import java.net.MalformedURLException; import java.net.URL; -import java.util.Collection; -import java.util.Map; -import java.util.Optional; +import java.util.*; import java.util.stream.Collectors; import javax.servlet.http.HttpServletResponse; import javax.validation.constraints.NotNull; +import org.glassfish.jersey.uri.UriComponent; import org.joda.time.DateTime; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -17,6 +16,8 @@ import org.springframework.http.HttpEntity; import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.oauth2.client.registration.ClientRegistration; import org.springframework.security.oauth2.core.oidc.OidcIdToken; import org.springframework.stereotype.Service; @@ -35,6 +36,8 @@ import de.tum.in.www1.artemis.web.rest.errors.BadRequestAlertException; import net.minidev.json.JSONObject; +import static de.tum.in.www1.artemis.service.connectors.lti.LtiService.SIMPLE_USER_LIST_AUTHORITY; + @Service @Profile("lti") public class Lti13Service { @@ -59,11 +62,13 @@ public class Lti13Service { private final OnlineCourseConfigurationService onlineCourseConfigurationService; + private final LtiDeepLinkingService ltiDeepLinkingService; + private final RestTemplate restTemplate; public Lti13Service(UserRepository userRepository, ExerciseRepository exerciseRepository, CourseRepository courseRepository, Lti13ResourceLaunchRepository launchRepository, LtiService ltiService, ResultRepository resultRepository, Lti13TokenRetriever tokenRetriever, OnlineCourseConfigurationService onlineCourseConfigurationService, - RestTemplate restTemplate) { + RestTemplate restTemplate, LtiDeepLinkingService ltiDeepLinkingService) { this.userRepository = userRepository; this.exerciseRepository = exerciseRepository; this.courseRepository = courseRepository; @@ -73,6 +78,7 @@ public Lti13Service(UserRepository userRepository, ExerciseRepository exerciseRe this.tokenRetriever = tokenRetriever; this.onlineCourseConfigurationService = onlineCourseConfigurationService; this.restTemplate = restTemplate; + this.ltiDeepLinkingService = ltiDeepLinkingService; } /** @@ -295,4 +301,26 @@ private void createOrUpdateResourceLaunch(Lti13LaunchRequest launchRequest, User public void buildLtiResponse(UriComponentsBuilder uriComponentsBuilder, HttpServletResponse response) { ltiService.buildLtiResponse(uriComponentsBuilder, response); } + + public void buildLtiDeepLinkResponse(OidcIdToken ltiIdToken, UriComponentsBuilder uriComponentsBuilder, String clientRegistrationId){ + + ltiDeepLinkingService.buildLtiDeepLinkingResponse(ltiIdToken); + ltiDeepLinkingService.setupDeepLinkingSettings(ltiIdToken); + var deepLinkResponse = ltiDeepLinkingService.getDeepLinkingResponse(); + + Map claims = new HashMap(); + for (var entry : deepLinkResponse.entrySet()) { + claims.put(entry.getKey(), entry.getValue().getAsString()); + } + String jwt = tokenRetriever.createDeepLinkingJWT(clientRegistrationId, claims); + uriComponentsBuilder.queryParam("jwt", jwt); + uriComponentsBuilder.queryParam("id", ltiIdToken.getClaim(Claims.LTI_DEPLOYMENT_ID).toString()); + uriComponentsBuilder.queryParam("deepLinkUri", UriComponent.encode(ltiDeepLinkingService.getDeepLinkingSettings().get("deep_link_return_url").toString(), UriComponent.Type.QUERY_PARAM) ); + + } + + public void authenticateLtiUser(OidcIdToken ltiIdToken){ + var user = userRepository.findOneByEmailIgnoreCase(ltiIdToken.getEmail()).orElseThrow(); + SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(user.getLogin(), user.getPassword(), SIMPLE_USER_LIST_AUTHORITY)); + } } diff --git a/src/main/java/de/tum/in/www1/artemis/service/connectors/lti/LtiDeepLinkingService.java b/src/main/java/de/tum/in/www1/artemis/service/connectors/lti/LtiDeepLinkingService.java new file mode 100644 index 000000000000..76097eb568b6 --- /dev/null +++ b/src/main/java/de/tum/in/www1/artemis/service/connectors/lti/LtiDeepLinkingService.java @@ -0,0 +1,75 @@ +package de.tum.in.www1.artemis.service.connectors.lti; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.context.annotation.Profile; +import org.springframework.security.oauth2.core.oidc.OidcIdToken; +import org.springframework.stereotype.Service; + +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; + +import de.tum.in.www1.artemis.domain.lti.Claims; +import net.minidev.json.JSONObject; + +@Service +@Profile("lti") +public class LtiDeepLinkingService { + + private final Logger log = LoggerFactory.getLogger(LtiDeepLinkingService.class); + + private JsonObject deepLinkingResponse; + + private JSONObject deepLinkingSettings; + + public LtiDeepLinkingService() { + this.deepLinkingResponse = new JsonObject(); + this.deepLinkingSettings = new JSONObject(); + } + + public void buildLtiDeepLinkingResponse(OidcIdToken ltiIdToken) { + populateDeepLinkingResponse(ltiIdToken); + populateContentItems(); + } + + public void setupDeepLinkingSettings(OidcIdToken ltiIdToken) { + this.deepLinkingSettings = new JSONObject(ltiIdToken.getClaim(Claims.DEEP_LINKING_SETTINGS)); + } + + public JsonObject getDeepLinkingResponse() { + return deepLinkingResponse; + } + + public JSONObject getDeepLinkingSettings() { + return deepLinkingSettings; + } + + private void populateDeepLinkingResponse(OidcIdToken ltiIdToken) { + this.deepLinkingResponse.addProperty("aud", ltiIdToken.getClaim("iss").toString()); + this.deepLinkingResponse.addProperty("iss", ltiIdToken.getClaim("aud").toString().replace("[", "").replace("]", "")); // "http://localhost:9000/" + this.deepLinkingResponse.addProperty("exp", ltiIdToken.getClaim("exp").toString()); // "1510185728" + this.deepLinkingResponse.addProperty("iat", ltiIdToken.getClaim("iat").toString()); // "1510185228" + this.deepLinkingResponse.addProperty("nonce", ltiIdToken.getClaim("nonce").toString()); + this.deepLinkingResponse.addProperty(Claims.MSG, "Content successfully linked"); + this.deepLinkingResponse.addProperty(Claims.LTI_DEPLOYMENT_ID, ltiIdToken.getClaim(Claims.LTI_DEPLOYMENT_ID).toString()); + this.deepLinkingResponse.addProperty(Claims.MESSAGE_TYPE, "LtiDeepLinkingResponse"); + this.deepLinkingResponse.addProperty(Claims.LTI_VERSION, "1.3.0"); + } + + private void populateContentItems() { + JsonObject item = createContentItem("ltiResourceLink", "A title", "http://localhost:9000/courses/3/exercises/82"); + JsonObject item2 = createContentItem("ltiResourceLink", "A title2", "http://localhost:9000/courses/3/exercises/81"); + + JsonArray contentItems = new JsonArray(); + contentItems.add(item); + this.deepLinkingResponse.addProperty(Claims.CONTENT_ITEMS, contentItems.toString()); + } + + private JsonObject createContentItem(String type, String title, String url) { + JsonObject item = new JsonObject(); + item.addProperty("type", type); + item.addProperty("title", title); + item.addProperty("url", url); + return item; + } +} diff --git a/src/main/webapp/app/lti/lti.module.ts b/src/main/webapp/app/lti/lti.module.ts index a4615fa3b68e..cbd0701e2a99 100644 --- a/src/main/webapp/app/lti/lti.module.ts +++ b/src/main/webapp/app/lti/lti.module.ts @@ -5,12 +5,14 @@ import { Lti13ExerciseLaunchComponent } from 'app/lti/lti13-exercise-launch.comp import { Lti13DynamicRegistrationComponent } from 'app/lti/lti13-dynamic-registration.component'; import { ArtemisCoreModule } from 'app/core/core.module'; import { ltiLaunchState } from './lti.route'; +import { Lti13DeepLinkingComponent } from 'app/lti/lti13-deep-linking.component'; +import { FormsModule } from '@angular/forms'; const LTI_LAUNCH_ROUTES = [...ltiLaunchState]; @NgModule({ - imports: [RouterModule.forChild(LTI_LAUNCH_ROUTES), ArtemisCoreModule, ArtemisSharedModule], - declarations: [Lti13ExerciseLaunchComponent, Lti13DynamicRegistrationComponent], - exports: [Lti13ExerciseLaunchComponent, Lti13DynamicRegistrationComponent], + imports: [RouterModule.forChild(LTI_LAUNCH_ROUTES), ArtemisCoreModule, ArtemisSharedModule, FormsModule], + declarations: [Lti13ExerciseLaunchComponent, Lti13DynamicRegistrationComponent, Lti13DeepLinkingComponent], + exports: [Lti13ExerciseLaunchComponent, Lti13DynamicRegistrationComponent, Lti13DeepLinkingComponent], }) export class ArtemisLtiModule {} diff --git a/src/main/webapp/app/lti/lti.route.ts b/src/main/webapp/app/lti/lti.route.ts index 9d2d6b1b98fc..501f911073ae 100644 --- a/src/main/webapp/app/lti/lti.route.ts +++ b/src/main/webapp/app/lti/lti.route.ts @@ -19,7 +19,7 @@ export const ltiLaunchRoutes: Routes = [ }, }, { - path: 'deep-linking/:courseId', + path: 'deep-linking', component: Lti13DeepLinkingComponent, data: { pageTitle: 'artemisApp.lti13.deepLinking.title', diff --git a/src/main/webapp/app/lti/lti13-deep-linking.component.html b/src/main/webapp/app/lti/lti13-deep-linking.component.html index 9f6d1e63f282..465aad0ddabf 100644 --- a/src/main/webapp/app/lti/lti13-deep-linking.component.html +++ b/src/main/webapp/app/lti/lti13-deep-linking.component.html @@ -1 +1,7 @@

Linking...

+
+
+ + +
+
diff --git a/src/main/webapp/app/lti/lti13-deep-linking.component.ts b/src/main/webapp/app/lti/lti13-deep-linking.component.ts index 53d68ee786c0..7ad1ef08265f 100644 --- a/src/main/webapp/app/lti/lti13-deep-linking.component.ts +++ b/src/main/webapp/app/lti/lti13-deep-linking.component.ts @@ -1,44 +1,38 @@ import { Component, OnInit } from '@angular/core'; import { ActivatedRoute } from '@angular/router'; -import { HttpClient } from '@angular/common/http'; @Component({ - selector: 'jhi-dynamic-registration', - templateUrl: './lti13-dynamic-registration.component.html', + selector: 'jhi-deep-linking', + templateUrl: './lti13-deep-linking.component.html', }) export class Lti13DeepLinkingComponent implements OnInit { courseId: number; - isRegistering = true; registeredSuccessfully: boolean; + jwt: string; + id: string; + actionLink: string; + response: string; - constructor( - private route: ActivatedRoute, - private http: HttpClient, - ) {} + constructor(private route: ActivatedRoute) {} /** * perform LTI 13 deep linking */ ngOnInit(): void { - this.route.params.subscribe((params) => { - this.courseId = Number(params['courseId']); + this.route.params.subscribe(() => { + this.jwt = this.route.snapshot.queryParamMap.get('jwt') ?? ''; + this.id = this.route.snapshot.queryParamMap.get('id') ?? ''; + this.actionLink = this.route.snapshot.queryParamMap.get('deepLinkUri') ?? ''; + this.autoSubmitForm(); }); + } - this.http.post('api/public/lti13/deep-linking', null); - - // this.http - // .post(`api/lti13/dynamic-registration/`, null, { observe: 'response', params: httpParams }) - // .subscribe({ - // next: () => { - // this.registeredSuccessfully = true; - // }, - // error: () => { - // this.registeredSuccessfully = false; - // }, - // }) - // .add(() => { - // this.isRegistering = false; - // (window.opener || window.parent).postMessage({ subject: 'org.imsglobal.lti.close' }, '*'); - // }); + autoSubmitForm(): void { + const form = document.getElementById('deepLinkingForm') as HTMLFormElement; + form.action = this.actionLink; + console.log(this.actionLink); + (document.getElementById('JWT'))!.value = this.jwt; + (document.getElementById('id'))!.value = this.id; + form.submit(); } } diff --git a/src/test/java/de/tum/in/www1/artemis/connectors/Lti13ServiceTest.java b/src/test/java/de/tum/in/www1/artemis/connectors/Lti13ServiceTest.java index 4a678b367840..a0bc9173e47b 100644 --- a/src/test/java/de/tum/in/www1/artemis/connectors/Lti13ServiceTest.java +++ b/src/test/java/de/tum/in/www1/artemis/connectors/Lti13ServiceTest.java @@ -11,6 +11,7 @@ import javax.servlet.http.HttpServletResponse; +import de.tum.in.www1.artemis.service.connectors.lti.LtiDeepLinkingService; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -73,6 +74,9 @@ class Lti13ServiceTest { @Mock private RestTemplate restTemplate; + @Mock + private LtiDeepLinkingService ltiDeepLinkingService; + private OidcIdToken oidcIdToken; private String clientRegistrationId; @@ -85,7 +89,7 @@ class Lti13ServiceTest { void init() { closeable = MockitoAnnotations.openMocks(this); lti13Service = new Lti13Service(userRepository, exerciseRepository, courseRepository, launchRepository, ltiService, resultRepository, tokenRetriever, - onlineCourseConfigurationService, restTemplate); + onlineCourseConfigurationService, restTemplate, ltiDeepLinkingService); clientRegistrationId = "clientId"; onlineCourseConfiguration = new OnlineCourseConfiguration(); onlineCourseConfiguration.setUserPrefix("prefix"); From da4d81f747f41fe0ab46f62b92f5a163516659cc Mon Sep 17 00:00:00 2001 From: Basak Akan Date: Tue, 17 Oct 2023 21:44:03 +0200 Subject: [PATCH 22/88] implement feedback from Maximilian --- .../security/lti/Lti13LaunchFilter.java | 4 + .../lti/lti13-exercise-launch.component.ts | 76 ++++++++++++------- 2 files changed, 51 insertions(+), 29 deletions(-) diff --git a/src/main/java/de/tum/in/www1/artemis/security/lti/Lti13LaunchFilter.java b/src/main/java/de/tum/in/www1/artemis/security/lti/Lti13LaunchFilter.java index 96193c3ae36c..be1323f7689d 100644 --- a/src/main/java/de/tum/in/www1/artemis/security/lti/Lti13LaunchFilter.java +++ b/src/main/java/de/tum/in/www1/artemis/security/lti/Lti13LaunchFilter.java @@ -54,6 +54,8 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse filterChain.doFilter(request, response); return; } + + // Initialize targetLink as an empty string here to ensure it has a value even if an exception is caught later. String targetLink = ""; try { OidcAuthenticationToken authToken = finishOidcFlow(request, response); @@ -71,6 +73,8 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, "LTI 1.3 Launch failed"); } catch (LtiEmailAlreadyInUseException ex) { + // LtiEmailAlreadyInUseException is thrown in case of user who has email address in use is not authenticated after targetLink is set + // We need targetLink to redirect user on the client-side after successful authentication response.setHeader("TargetLinkUri", targetLink); response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "LTI 1.3 user authentication failed"); } diff --git a/src/main/webapp/app/lti/lti13-exercise-launch.component.ts b/src/main/webapp/app/lti/lti13-exercise-launch.component.ts index a4fb7fbb558b..028206a14f82 100644 --- a/src/main/webapp/app/lti/lti13-exercise-launch.component.ts +++ b/src/main/webapp/app/lti/lti13-exercise-launch.component.ts @@ -54,41 +54,59 @@ export class Lti13ExerciseLaunchComponent implements OnInit { }) .subscribe({ next: (data) => { - const targetLinkUri = data['targetLinkUri']; - window.sessionStorage.removeItem('state'); - - if (targetLinkUri) { - window.location.replace(targetLinkUri); - } else { - this.isLaunching = false; - console.error('No LTI targetLinkUri received for a successful launch'); - } + this.handleLtiLaunchSuccess(data); }, error: (error) => { if (error.status === 401) { - this.accountService.identity().then((user) => { - if (user) { - if (user) { - // resend request since user is already logged in - window.location.replace(error.headers.get('TargetLinkUri').toString()); - } - } else { - // Redirect the user to the login page - this.router.navigate(['/']).then(() => { - // After navigating to the login page, set up a listener for when the user logs in - this.accountService.getAuthenticationState().subscribe((user) => { - if (user) { - window.location.replace(error.headers.get('TargetLinkUri').toString()); - } - }); - }); - } - }); + this.authenticateUserThenRedirect(error); } else { - window.sessionStorage.removeItem('state'); - this.isLaunching = false; + this.handleLtiLaunchError(); } }, }); } + + authenticateUserThenRedirect(error: any): void { + this.accountService.identity().then((user) => { + if (user) { + this.redirectUserToTargetLink(error); + } else { + this.redirectUserToLoginThenTargetLink(error); + } + }); + } + + redirectUserToTargetLink(error: any): void { + // Redirect to target link since the user is already logged in + window.location.replace(error.headers.get('TargetLinkUri').toString()); + } + + redirectUserToLoginThenTargetLink(error: any): void { + // Redirect the user to the login page + this.router.navigate(['/']).then(() => { + // After navigating to the login page, set up a listener for when the user logs in + this.accountService.getAuthenticationState().subscribe((user) => { + if (user) { + this.redirectUserToTargetLink(error); + } + }); + }); + } + + handleLtiLaunchSuccess(data: NonNullable): void { + const targetLinkUri = data['targetLinkUri']; + window.sessionStorage.removeItem('state'); + + if (targetLinkUri) { + window.location.replace(targetLinkUri); + } else { + this.isLaunching = false; + console.error('No LTI targetLinkUri received for a successful launch'); + } + } + + handleLtiLaunchError(): void { + window.sessionStorage.removeItem('state'); + this.isLaunching = false; + } } From f6cc2a96ece90de8614edbb6d3a4ebbd8ca70312 Mon Sep 17 00:00:00 2001 From: Basak Akan Date: Wed, 18 Oct 2023 00:26:29 +0200 Subject: [PATCH 23/88] implement client with server adaptations --- .../security/lti/Lti13LaunchFilter.java | 31 +----- .../service/connectors/lti/Lti13Service.java | 61 +++++++--- .../connectors/lti/LtiDeepLinkingService.java | 70 ++++++++---- .../in/www1/artemis/web/rest/LtiResource.java | 20 +++- src/main/webapp/app/lti/lti.module.ts | 5 +- src/main/webapp/app/lti/lti.route.ts | 10 +- .../app/lti/lti13-deep-linking.component.html | 4 +- .../lti/lti13-select-content.component.html | 53 +++++++++ .../app/lti/lti13-select-content.component.ts | 104 ++++++++++++++++++ 9 files changed, 283 insertions(+), 75 deletions(-) create mode 100644 src/main/webapp/app/lti/lti13-select-content.component.html create mode 100644 src/main/webapp/app/lti/lti13-select-content.component.ts diff --git a/src/main/java/de/tum/in/www1/artemis/security/lti/Lti13LaunchFilter.java b/src/main/java/de/tum/in/www1/artemis/security/lti/Lti13LaunchFilter.java index 2516ead605d2..a300af773d82 100644 --- a/src/main/java/de/tum/in/www1/artemis/security/lti/Lti13LaunchFilter.java +++ b/src/main/java/de/tum/in/www1/artemis/security/lti/Lti13LaunchFilter.java @@ -19,8 +19,6 @@ import org.springframework.web.filter.OncePerRequestFilter; import org.springframework.web.util.UriComponentsBuilder; -import com.nimbusds.jose.JOSEException; - import de.tum.in.www1.artemis.domain.lti.Claims; import de.tum.in.www1.artemis.service.connectors.lti.Lti13Service; import net.minidev.json.JSONObject; @@ -64,8 +62,8 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse // here we need to check if this is a deep-linking request or a launch request if (ltiIdToken.getClaim(Claims.MESSAGE_TYPE).equals("LtiDeepLinkingRequest")) { - lti13DeepLinking(ltiIdToken, response, authToken.getAuthorizedClientRegistrationId()); - + lti13Service.startDeepLinking(ltiIdToken, authToken.getAuthorizedClientRegistrationId()); + writeResponse(ltiIdToken.getClaim(Claims.TARGET_LINK_URI).toString().replace("api/public/lti13", "lti"), response); } else { lti13Service.performLaunch(ltiIdToken, authToken.getAuthorizedClientRegistrationId()); @@ -78,9 +76,6 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse log.error("Error during LTI 1.3 launch request: {}", ex.getMessage()); response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, "LTI 1.3 Launch failed"); } - catch (JOSEException e) { - throw new RuntimeException(e); - } } private OidcAuthenticationToken finishOidcFlow(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { @@ -113,26 +108,4 @@ private void writeResponse(String targetLinkUri, HttpServletResponse response) t writer.print(json); writer.flush(); } - - private void lti13DeepLinking(OidcIdToken ltiIdToken, HttpServletResponse response, String clientRegistrationId) throws IOException, JOSEException { - - lti13Service.authenticateLtiUser(ltiIdToken); - - PrintWriter writer = response.getWriter(); - UriComponentsBuilder uriBuilder = UriComponentsBuilder.fromUriString("http://localhost:9000/lti/deep-linking"); - lti13Service.buildLtiDeepLinkResponse(ltiIdToken, uriBuilder, clientRegistrationId); - - response.setContentType("application/json"); - response.setCharacterEncoding("UTF-8"); - - JSONObject json = new JSONObject(); - json.put("targetLinkUri", uriBuilder.build().toUriString()); - - response.setContentType("application/json"); - response.setCharacterEncoding("UTF-8"); - - writer.print(json); - writer.flush(); - - } } diff --git a/src/main/java/de/tum/in/www1/artemis/service/connectors/lti/Lti13Service.java b/src/main/java/de/tum/in/www1/artemis/service/connectors/lti/Lti13Service.java index e59a54bb06ae..fcdc85efb25c 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/connectors/lti/Lti13Service.java +++ b/src/main/java/de/tum/in/www1/artemis/service/connectors/lti/Lti13Service.java @@ -8,7 +8,6 @@ import javax.servlet.http.HttpServletResponse; import javax.validation.constraints.NotNull; -import org.glassfish.jersey.uri.UriComponent; import org.joda.time.DateTime; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -17,6 +16,7 @@ import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.oauth2.client.registration.ClientRegistration; import org.springframework.security.oauth2.core.oidc.OidcIdToken; @@ -31,19 +31,20 @@ import de.tum.in.www1.artemis.domain.lti.*; import de.tum.in.www1.artemis.domain.participation.StudentParticipation; import de.tum.in.www1.artemis.repository.*; +import de.tum.in.www1.artemis.security.Role; import de.tum.in.www1.artemis.security.lti.Lti13TokenRetriever; import de.tum.in.www1.artemis.service.OnlineCourseConfigurationService; import de.tum.in.www1.artemis.web.rest.errors.BadRequestAlertException; import net.minidev.json.JSONObject; -import static de.tum.in.www1.artemis.service.connectors.lti.LtiService.SIMPLE_USER_LIST_AUTHORITY; - @Service @Profile("lti") public class Lti13Service { private static final String EXERCISE_PATH_PATTERN = "/courses/{courseId}/exercises/{exerciseId}"; + private static final String COURSE_PATH_PATTERN = "/api/public/lti13/deep-linking/{courseId}"; + private final Logger log = LoggerFactory.getLogger(Lti13Service.class); private final UserRepository userRepository; @@ -275,6 +276,31 @@ private Optional getExerciseFromTargetLink(String targetLinkUrl) { return exerciseOpt; } + private Course getCourseFromTargetLink(String targetLinkUrl) { + AntPathMatcher matcher = new AntPathMatcher(); + + String targetLinkPath; + try { + targetLinkPath = (new URL(targetLinkUrl)).getPath(); + } + catch (MalformedURLException ex) { + log.info("Malformed target link url: {}", targetLinkUrl); + return null; + } + + if (!matcher.match(COURSE_PATH_PATTERN, targetLinkPath)) { + log.info("Could not extract courseId from target link: {}", targetLinkUrl); + return null; + } + Map pathVariables = matcher.extractUriTemplateVariables(COURSE_PATH_PATTERN, targetLinkPath); + + String courseId = pathVariables.get("courseId"); + + Course course = courseRepository.findByIdWithEagerOnlineCourseConfigurationElseThrow(Long.valueOf(courseId)); + + return course; + } + private void createOrUpdateResourceLaunch(Lti13LaunchRequest launchRequest, User user, Exercise exercise) { Optional launchOpt = launchRepository.findByIssAndSubAndDeploymentIdAndResourceLinkId(launchRequest.getIss(), launchRequest.getSub(), launchRequest.getDeploymentId(), launchRequest.getResourceLinkId()); @@ -302,25 +328,26 @@ public void buildLtiResponse(UriComponentsBuilder uriComponentsBuilder, HttpServ ltiService.buildLtiResponse(uriComponentsBuilder, response); } - public void buildLtiDeepLinkResponse(OidcIdToken ltiIdToken, UriComponentsBuilder uriComponentsBuilder, String clientRegistrationId){ + public void startDeepLinking(OidcIdToken ltiIdToken, String clientRegistrationId) { - ltiDeepLinkingService.buildLtiDeepLinkingResponse(ltiIdToken); - ltiDeepLinkingService.setupDeepLinkingSettings(ltiIdToken); - var deepLinkResponse = ltiDeepLinkingService.getDeepLinkingResponse(); + String targetLinkUrl = ltiIdToken.getClaim(Claims.TARGET_LINK_URI); + Course targetCourse = getCourseFromTargetLink(targetLinkUrl); - Map claims = new HashMap(); - for (var entry : deepLinkResponse.entrySet()) { - claims.put(entry.getKey(), entry.getValue().getAsString()); + OnlineCourseConfiguration onlineCourseConfiguration = targetCourse.getOnlineCourseConfiguration(); + if (onlineCourseConfiguration == null) { + String message = "Exercise is not related to course for target link url: " + targetLinkUrl; + log.error(message); + throw new BadRequestAlertException("LTI is not configured for this course", "LTI", "ltiNotConfigured"); } - String jwt = tokenRetriever.createDeepLinkingJWT(clientRegistrationId, claims); - uriComponentsBuilder.queryParam("jwt", jwt); - uriComponentsBuilder.queryParam("id", ltiIdToken.getClaim(Claims.LTI_DEPLOYMENT_ID).toString()); - uriComponentsBuilder.queryParam("deepLinkUri", UriComponent.encode(ltiDeepLinkingService.getDeepLinkingSettings().get("deep_link_return_url").toString(), UriComponent.Type.QUERY_PARAM) ); - } + // ltiService.authenticateLtiUser(ltiIdToken.getEmail(), createUsernameFromLaunchRequest(ltiIdToken, onlineCourseConfiguration), ltiIdToken.getGivenName(), + // ltiIdToken.getFamilyName(), onlineCourseConfiguration.isRequireExistingUser()); - public void authenticateLtiUser(OidcIdToken ltiIdToken){ var user = userRepository.findOneByEmailIgnoreCase(ltiIdToken.getEmail()).orElseThrow(); - SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(user.getLogin(), user.getPassword(), SIMPLE_USER_LIST_AUTHORITY)); + SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(user.getLogin(), user.getPassword(), + Collections.singletonList(new SimpleGrantedAuthority(Role.INSTRUCTOR.getAuthority())))); + + ltiDeepLinkingService.populateDeepLinkingResponse(ltiIdToken); + ltiDeepLinkingService.setupDeepLinkingSettings(ltiIdToken, clientRegistrationId); } } diff --git a/src/main/java/de/tum/in/www1/artemis/service/connectors/lti/LtiDeepLinkingService.java b/src/main/java/de/tum/in/www1/artemis/service/connectors/lti/LtiDeepLinkingService.java index 76097eb568b6..1dd37d0b2714 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/connectors/lti/LtiDeepLinkingService.java +++ b/src/main/java/de/tum/in/www1/artemis/service/connectors/lti/LtiDeepLinkingService.java @@ -1,54 +1,80 @@ package de.tum.in.www1.artemis.service.connectors.lti; +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; + +import org.glassfish.jersey.uri.UriComponent; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Profile; import org.springframework.security.oauth2.core.oidc.OidcIdToken; import org.springframework.stereotype.Service; +import org.springframework.web.util.UriComponentsBuilder; import com.google.gson.JsonArray; import com.google.gson.JsonObject; +import de.tum.in.www1.artemis.domain.Exercise; import de.tum.in.www1.artemis.domain.lti.Claims; +import de.tum.in.www1.artemis.repository.ExerciseRepository; +import de.tum.in.www1.artemis.security.lti.Lti13TokenRetriever; import net.minidev.json.JSONObject; @Service @Profile("lti") public class LtiDeepLinkingService { + @Value("${server.url}") + private String artemisServerUrl; + private final Logger log = LoggerFactory.getLogger(LtiDeepLinkingService.class); + private final ExerciseRepository exerciseRepository; + + private final Lti13TokenRetriever tokenRetriever; + private JsonObject deepLinkingResponse; private JSONObject deepLinkingSettings; - public LtiDeepLinkingService() { + private String deploymentId; + + private String clientRegistrationId; + + public LtiDeepLinkingService(ExerciseRepository exerciseRepository, Lti13TokenRetriever tokenRetriever) { + this.exerciseRepository = exerciseRepository; + this.tokenRetriever = tokenRetriever; this.deepLinkingResponse = new JsonObject(); this.deepLinkingSettings = new JSONObject(); } - public void buildLtiDeepLinkingResponse(OidcIdToken ltiIdToken) { - populateDeepLinkingResponse(ltiIdToken); - populateContentItems(); + public String buildLtiDeepLinkResponse() { + UriComponentsBuilder uriComponentsBuilder = UriComponentsBuilder.fromUriString(this.artemisServerUrl + "/lti/select-content"); + Map claims = new HashMap(); + for (var entry : deepLinkingResponse.entrySet()) { + claims.put(entry.getKey(), entry.getValue().getAsString()); + } + String jwt = tokenRetriever.createDeepLinkingJWT(this.clientRegistrationId, claims); + uriComponentsBuilder.queryParam("jwt", jwt); + uriComponentsBuilder.queryParam("id", this.deploymentId); + uriComponentsBuilder.queryParam("deepLinkUri", UriComponent.encode(this.deepLinkingSettings.getAsString("deep_link_return_url"), UriComponent.Type.QUERY_PARAM)); + + return uriComponentsBuilder.build().toUriString(); } - public void setupDeepLinkingSettings(OidcIdToken ltiIdToken) { + public void setupDeepLinkingSettings(OidcIdToken ltiIdToken, String clientRegistrationId) { this.deepLinkingSettings = new JSONObject(ltiIdToken.getClaim(Claims.DEEP_LINKING_SETTINGS)); + this.deploymentId = ltiIdToken.getClaim(Claims.LTI_DEPLOYMENT_ID).toString(); + this.clientRegistrationId = clientRegistrationId; } - public JsonObject getDeepLinkingResponse() { - return deepLinkingResponse; - } - - public JSONObject getDeepLinkingSettings() { - return deepLinkingSettings; - } - - private void populateDeepLinkingResponse(OidcIdToken ltiIdToken) { + public void populateDeepLinkingResponse(OidcIdToken ltiIdToken) { this.deepLinkingResponse.addProperty("aud", ltiIdToken.getClaim("iss").toString()); this.deepLinkingResponse.addProperty("iss", ltiIdToken.getClaim("aud").toString().replace("[", "").replace("]", "")); // "http://localhost:9000/" - this.deepLinkingResponse.addProperty("exp", ltiIdToken.getClaim("exp").toString()); // "1510185728" - this.deepLinkingResponse.addProperty("iat", ltiIdToken.getClaim("iat").toString()); // "1510185228" + this.deepLinkingResponse.addProperty("exp", ltiIdToken.getClaim("exp").toString()); + this.deepLinkingResponse.addProperty("iat", ltiIdToken.getClaim("iat").toString()); this.deepLinkingResponse.addProperty("nonce", ltiIdToken.getClaim("nonce").toString()); this.deepLinkingResponse.addProperty(Claims.MSG, "Content successfully linked"); this.deepLinkingResponse.addProperty(Claims.LTI_DEPLOYMENT_ID, ltiIdToken.getClaim(Claims.LTI_DEPLOYMENT_ID).toString()); @@ -56,15 +82,21 @@ private void populateDeepLinkingResponse(OidcIdToken ltiIdToken) { this.deepLinkingResponse.addProperty(Claims.LTI_VERSION, "1.3.0"); } - private void populateContentItems() { - JsonObject item = createContentItem("ltiResourceLink", "A title", "http://localhost:9000/courses/3/exercises/82"); - JsonObject item2 = createContentItem("ltiResourceLink", "A title2", "http://localhost:9000/courses/3/exercises/81"); + public void populateContentItems(String courseId, String exerciseId) { + JsonObject item = setContentItem(courseId, exerciseId); JsonArray contentItems = new JsonArray(); contentItems.add(item); this.deepLinkingResponse.addProperty(Claims.CONTENT_ITEMS, contentItems.toString()); } + private JsonObject setContentItem(String courseId, String exerciseId) { + Optional exerciseOpt = exerciseRepository.findById(Long.valueOf(exerciseId)); + + String launchUrl = String.format(artemisServerUrl + "/courses/%s/exercises/%s", courseId, exerciseId); + return createContentItem(exerciseOpt.get().getType(), exerciseOpt.get().getTitle(), launchUrl); + } + private JsonObject createContentItem(String type, String title, String url) { JsonObject item = new JsonObject(); item.addProperty("type", type); diff --git a/src/main/java/de/tum/in/www1/artemis/web/rest/LtiResource.java b/src/main/java/de/tum/in/www1/artemis/web/rest/LtiResource.java index 95d7d182513f..fdafde1e9c9c 100644 --- a/src/main/java/de/tum/in/www1/artemis/web/rest/LtiResource.java +++ b/src/main/java/de/tum/in/www1/artemis/web/rest/LtiResource.java @@ -1,6 +1,7 @@ package de.tum.in.www1.artemis.web.rest; import org.springframework.context.annotation.Profile; +import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; import de.tum.in.www1.artemis.domain.Course; @@ -8,7 +9,9 @@ import de.tum.in.www1.artemis.security.Role; import de.tum.in.www1.artemis.security.annotations.EnforceAtLeastInstructor; import de.tum.in.www1.artemis.service.AuthorizationCheckService; +import de.tum.in.www1.artemis.service.connectors.lti.LtiDeepLinkingService; import de.tum.in.www1.artemis.service.connectors.lti.LtiDynamicRegistrationService; +import net.minidev.json.JSONObject; /** * REST controller to handle LTI10 launches. @@ -20,16 +23,20 @@ public class LtiResource { private final LtiDynamicRegistrationService ltiDynamicRegistrationService; + private final LtiDeepLinkingService ltiDeepLinkingService; + private final CourseRepository courseRepository; private final AuthorizationCheckService authCheckService; public static final String LOGIN_REDIRECT_CLIENT_PATH = "/lti/launch"; - public LtiResource(LtiDynamicRegistrationService ltiDynamicRegistrationService, CourseRepository courseRepository, AuthorizationCheckService authCheckService) { + public LtiResource(LtiDynamicRegistrationService ltiDynamicRegistrationService, CourseRepository courseRepository, AuthorizationCheckService authCheckService, + LtiDeepLinkingService ltiDeepLinkingService) { this.ltiDynamicRegistrationService = ltiDynamicRegistrationService; this.courseRepository = courseRepository; this.authCheckService = authCheckService; + this.ltiDeepLinkingService = ltiDeepLinkingService; } @PostMapping("/lti13/dynamic-registration/{courseId}") @@ -42,11 +49,14 @@ public void lti13DynamicRegistration(@PathVariable Long courseId, @RequestParam( ltiDynamicRegistrationService.performDynamicRegistration(course, openIdConfiguration, registrationToken); } - @PostMapping("/lti13/deep-linking") + @PostMapping("/lti13/deep-linking/{courseId}") @EnforceAtLeastInstructor - public void lti13DynamicRegistration(String platformReturnUrl) { - - // here create a dummy response to send back to the platform + public ResponseEntity lti13DynamicRegistration(@PathVariable Long courseId, @RequestParam(name = "exerciseId") String exerciseId) { + ltiDeepLinkingService.populateContentItems(String.valueOf(courseId), exerciseId); + String targetLink = ltiDeepLinkingService.buildLtiDeepLinkResponse(); + JSONObject json = new JSONObject(); + json.put("targetLinkUri", targetLink); + return ResponseEntity.ok(json.toJSONString()); } } diff --git a/src/main/webapp/app/lti/lti.module.ts b/src/main/webapp/app/lti/lti.module.ts index cbd0701e2a99..1aedec6b34c3 100644 --- a/src/main/webapp/app/lti/lti.module.ts +++ b/src/main/webapp/app/lti/lti.module.ts @@ -7,12 +7,13 @@ import { ArtemisCoreModule } from 'app/core/core.module'; import { ltiLaunchState } from './lti.route'; import { Lti13DeepLinkingComponent } from 'app/lti/lti13-deep-linking.component'; import { FormsModule } from '@angular/forms'; +import { Lti13SelectContentComponent } from 'app/lti/lti13-select-content.component'; const LTI_LAUNCH_ROUTES = [...ltiLaunchState]; @NgModule({ imports: [RouterModule.forChild(LTI_LAUNCH_ROUTES), ArtemisCoreModule, ArtemisSharedModule, FormsModule], - declarations: [Lti13ExerciseLaunchComponent, Lti13DynamicRegistrationComponent, Lti13DeepLinkingComponent], - exports: [Lti13ExerciseLaunchComponent, Lti13DynamicRegistrationComponent, Lti13DeepLinkingComponent], + declarations: [Lti13ExerciseLaunchComponent, Lti13DynamicRegistrationComponent, Lti13DeepLinkingComponent, Lti13SelectContentComponent], + exports: [Lti13ExerciseLaunchComponent, Lti13DynamicRegistrationComponent, Lti13DeepLinkingComponent, Lti13SelectContentComponent], }) export class ArtemisLtiModule {} diff --git a/src/main/webapp/app/lti/lti.route.ts b/src/main/webapp/app/lti/lti.route.ts index 501f911073ae..33b340e89f38 100644 --- a/src/main/webapp/app/lti/lti.route.ts +++ b/src/main/webapp/app/lti/lti.route.ts @@ -2,6 +2,7 @@ import { Routes } from '@angular/router'; import { Lti13ExerciseLaunchComponent } from 'app/lti/lti13-exercise-launch.component'; import { Lti13DynamicRegistrationComponent } from 'app/lti/lti13-dynamic-registration.component'; import { Lti13DeepLinkingComponent } from 'app/lti/lti13-deep-linking.component'; +import { Lti13SelectContentComponent } from 'app/lti/lti13-select-content.component'; export const ltiLaunchRoutes: Routes = [ { @@ -19,12 +20,19 @@ export const ltiLaunchRoutes: Routes = [ }, }, { - path: 'deep-linking', + path: 'select-content', component: Lti13DeepLinkingComponent, data: { pageTitle: 'artemisApp.lti13.deepLinking.title', }, }, + { + path: 'deep-linking/:courseId', + component: Lti13SelectContentComponent, + data: { + pageTitle: 'artemisApp.lti13.deepLinking.title', + }, + }, ]; const LTI_LAUNCH_ROUTES = [...ltiLaunchRoutes]; diff --git a/src/main/webapp/app/lti/lti13-deep-linking.component.html b/src/main/webapp/app/lti/lti13-deep-linking.component.html index 465aad0ddabf..ef296c9d3481 100644 --- a/src/main/webapp/app/lti/lti13-deep-linking.component.html +++ b/src/main/webapp/app/lti/lti13-deep-linking.component.html @@ -1,7 +1,7 @@

Linking...

- - + +
diff --git a/src/main/webapp/app/lti/lti13-select-content.component.html b/src/main/webapp/app/lti/lti13-select-content.component.html new file mode 100644 index 000000000000..b04a007afd4a --- /dev/null +++ b/src/main/webapp/app/lti/lti13-select-content.component.html @@ -0,0 +1,53 @@ +
+ + + + + + + + + + + + + + + + + + + + + + + +
+ + + id + + + {{ 'artemisApp.exercise.type' | artemisTranslate }} + + + {{ 'artemisApp.exercise.title' | artemisTranslate }} + + + {{ 'artemisApp.exercise.releaseDate' | artemisTranslate }} + + + {{ 'artemisApp.exercise.dueDate' | artemisTranslate }} + + + {{ 'artemisApp.exercise.assessmentDueDate' | artemisTranslate }} + +
+ + {{ exercise.id ? exercise.id : '' }}{{ exercise.type ? exercise.type : '' }}{{ exercise.title ? exercise.title : '' }}{{ exercise.releaseDate ? exercise.releaseDate.format('MMM DD YYYY, HH:mm:ss') : '' }}{{ exercise.dueDate ? exercise.dueDate.format('MMM DD YYYY, HH:mm:ss') : '' }}{{ exercise.assessmentDueDate ? exercise.assessmentDueDate.format('MMM DD YYYY, HH:mm:ss') : '' }}
+ + +
diff --git a/src/main/webapp/app/lti/lti13-select-content.component.ts b/src/main/webapp/app/lti/lti13-select-content.component.ts new file mode 100644 index 000000000000..b1620c230420 --- /dev/null +++ b/src/main/webapp/app/lti/lti13-select-content.component.ts @@ -0,0 +1,104 @@ +import { Component, OnInit } from '@angular/core'; +import { ActivatedRoute, Router } from '@angular/router'; +import { OnlineCourseConfiguration } from 'app/entities/online-course-configuration.model'; +import { CourseManagementService } from 'app/course/manage/course-management.service'; +import { Exercise } from 'app/entities/exercise.model'; +import { faExclamationTriangle, faPlayCircle, faSort, faWrench } from '@fortawesome/free-solid-svg-icons'; +import { SortService } from 'app/shared/service/sort.service'; +import { HttpClient, HttpParams } from '@angular/common/http'; +import { AccountService } from 'app/core/auth/account.service'; + +@Component({ + selector: 'jhi-select-exercise', + templateUrl: './lti13-select-content.component.html', +}) +export class Lti13SelectContentComponent implements OnInit { + courseId: number; + onlineCourseConfiguration: OnlineCourseConfiguration; + exercises: Exercise[]; + selectedExercise: Exercise; + + activeTab = 1; + + predicate = 'type'; + reverse = false; + + // Icons + faSort = faSort; + faExclamationTriangle = faExclamationTriangle; + faWrench = faWrench; + faFileImport = faPlayCircle; + constructor( + private route: ActivatedRoute, + private sortService: SortService, + private courseManagementService: CourseManagementService, + private http: HttpClient, + private accountService: AccountService, + private router: Router, + ) {} + + /** + * Gets the configuration for the course encoded in the route and fetches the exercises + */ + ngOnInit() { + this.route.params.subscribe((params) => { + this.courseId = Number(params['courseId']); + this.accountService.identity().then((user) => { + if (user) { + this.courseManagementService.findWithExercises(this.courseId).subscribe((findWithExercisesResult) => { + if (findWithExercisesResult?.body?.exercises) { + this.exercises = findWithExercisesResult.body.exercises; + } + }); + } else { + this.redirectUserToLoginThenTargetLink(window.location.href); + } + }); + }); + } + + redirectUserToLoginThenTargetLink(currentLink: any): void { + // Redirect the user to the login page + this.router.navigate(['/']).then(() => { + // After navigating to the login page, set up a listener for when the user logs in + this.accountService.getAuthenticationState().subscribe((user) => { + if (user) { + window.location.replace(currentLink); + } + }); + }); + } + + sortRows() { + this.sortService.sortByProperty(this.exercises, this.predicate, this.reverse); + } + + toggleExercise(exercise: Exercise) { + this.selectedExercise = exercise; + } + + isExerciseSelected(exercise: Exercise) { + return this.selectedExercise === exercise; + } + + sendDeepLinkRequest() { + if (this.selectedExercise) { + const httpParams = new HttpParams().set('exerciseId', this.selectedExercise.id!); + this.http.post(`api/lti13/deep-linking/${this.courseId}`, null, { observe: 'response', params: httpParams }).subscribe({ + next: (response) => { + if (response.status === 200) { + if (response.body) { + const targetLink = response.body['targetLinkUri']; + window.location.replace(targetLink); + } + } else { + console.log('Unexpected response status:', response.status); + } + }, + error: (error) => { + console.error('An error occurred:', error); + }, + }); + } + } +} From ce6a83f8c7e6fff433dc7cb1b6f92ff534d8cd40 Mon Sep 17 00:00:00 2001 From: Basak Akan Date: Wed, 18 Oct 2023 01:00:52 +0200 Subject: [PATCH 24/88] minor button adjustments --- .../webapp/app/lti/lti13-select-content.component.html | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/main/webapp/app/lti/lti13-select-content.component.html b/src/main/webapp/app/lti/lti13-select-content.component.html index b04a007afd4a..c2d816c7180a 100644 --- a/src/main/webapp/app/lti/lti13-select-content.component.html +++ b/src/main/webapp/app/lti/lti13-select-content.component.html @@ -45,9 +45,10 @@ + - From 4730bdf8894ea9a29d6fb2350ec66a3653a3e08c Mon Sep 17 00:00:00 2001 From: Basak Akan Date: Thu, 19 Oct 2023 18:28:32 +0200 Subject: [PATCH 25/88] ui improvments --- src/main/webapp/app/lti/lti.module.ts | 3 +- .../lti/lti13-select-content.component.html | 127 +++++++++++------- .../app/lti/lti13-select-content.component.ts | 4 +- src/main/webapp/i18n/de/lti.json | 4 +- src/main/webapp/i18n/en/lti.json | 4 +- 5 files changed, 86 insertions(+), 56 deletions(-) diff --git a/src/main/webapp/app/lti/lti.module.ts b/src/main/webapp/app/lti/lti.module.ts index 1aedec6b34c3..0173267944ff 100644 --- a/src/main/webapp/app/lti/lti.module.ts +++ b/src/main/webapp/app/lti/lti.module.ts @@ -8,11 +8,12 @@ import { ltiLaunchState } from './lti.route'; import { Lti13DeepLinkingComponent } from 'app/lti/lti13-deep-linking.component'; import { FormsModule } from '@angular/forms'; import { Lti13SelectContentComponent } from 'app/lti/lti13-select-content.component'; +import { ArtemisSharedComponentModule } from 'app/shared/components/shared-component.module'; const LTI_LAUNCH_ROUTES = [...ltiLaunchState]; @NgModule({ - imports: [RouterModule.forChild(LTI_LAUNCH_ROUTES), ArtemisCoreModule, ArtemisSharedModule, FormsModule], + imports: [RouterModule.forChild(LTI_LAUNCH_ROUTES), ArtemisCoreModule, ArtemisSharedModule, FormsModule, ArtemisSharedComponentModule], declarations: [Lti13ExerciseLaunchComponent, Lti13DynamicRegistrationComponent, Lti13DeepLinkingComponent, Lti13SelectContentComponent], exports: [Lti13ExerciseLaunchComponent, Lti13DynamicRegistrationComponent, Lti13DeepLinkingComponent, Lti13SelectContentComponent], }) diff --git a/src/main/webapp/app/lti/lti13-select-content.component.html b/src/main/webapp/app/lti/lti13-select-content.component.html index c2d816c7180a..b27620ef770d 100644 --- a/src/main/webapp/app/lti/lti13-select-content.component.html +++ b/src/main/webapp/app/lti/lti13-select-content.component.html @@ -1,54 +1,77 @@ -
- - - - - - - - - - - - - - - - - - - - - - - -
- - - id - - - {{ 'artemisApp.exercise.type' | artemisTranslate }} - - - {{ 'artemisApp.exercise.title' | artemisTranslate }} - - - {{ 'artemisApp.exercise.releaseDate' | artemisTranslate }} - - - {{ 'artemisApp.exercise.dueDate' | artemisTranslate }} - - - {{ 'artemisApp.exercise.assessmentDueDate' | artemisTranslate }} - -
- - {{ exercise.id ? exercise.id : '' }}{{ exercise.type ? exercise.type : '' }}{{ exercise.title ? exercise.title : '' }}{{ exercise.releaseDate ? exercise.releaseDate.format('MMM DD YYYY, HH:mm:ss') : '' }}{{ exercise.dueDate ? exercise.dueDate.format('MMM DD YYYY, HH:mm:ss') : '' }}{{ exercise.assessmentDueDate ? exercise.assessmentDueDate.format('MMM DD YYYY, HH:mm:ss') : '' }}
-
-
- + + +
diff --git a/src/main/webapp/app/lti/lti13-select-content.component.ts b/src/main/webapp/app/lti/lti13-select-content.component.ts index b1620c230420..b1c971972658 100644 --- a/src/main/webapp/app/lti/lti13-select-content.component.ts +++ b/src/main/webapp/app/lti/lti13-select-content.component.ts @@ -7,6 +7,7 @@ import { faExclamationTriangle, faPlayCircle, faSort, faWrench } from '@fortawes import { SortService } from 'app/shared/service/sort.service'; import { HttpClient, HttpParams } from '@angular/common/http'; import { AccountService } from 'app/core/auth/account.service'; +import { Course } from 'app/entities/course.model'; @Component({ selector: 'jhi-select-exercise', @@ -17,7 +18,7 @@ export class Lti13SelectContentComponent implements OnInit { onlineCourseConfiguration: OnlineCourseConfiguration; exercises: Exercise[]; selectedExercise: Exercise; - + course: Course; activeTab = 1; predicate = 'type'; @@ -47,6 +48,7 @@ export class Lti13SelectContentComponent implements OnInit { if (user) { this.courseManagementService.findWithExercises(this.courseId).subscribe((findWithExercisesResult) => { if (findWithExercisesResult?.body?.exercises) { + this.course = findWithExercisesResult.body; this.exercises = findWithExercisesResult.body.exercises; } }); diff --git a/src/main/webapp/i18n/de/lti.json b/src/main/webapp/i18n/de/lti.json index efb4231818e2..fbfc00975e43 100644 --- a/src/main/webapp/i18n/de/lti.json +++ b/src/main/webapp/i18n/de/lti.json @@ -75,7 +75,9 @@ "registeredSuccessfully": "Kurs erfolgreich registriert", "registerFailed": "Fehler bei der dynamischen Registrierung" }, - "missingConfigurationWarning": "Fehlende Werte in der LTI1.3-Konfiguration. Starts werden nicht funktionieren." + "missingConfigurationWarning": "Fehlende Werte in der LTI1.3-Konfiguration. Starts werden nicht funktionieren.", + "selectContentFromCourse": "Wählen Sie Inhalte aus dem Kurs aus {{ title }}", + "selectContentTooltip": "Wählen Sie einfach Ihre bevorzugte Übung aus und klicken Sie dann auf die Schaltfläche Importieren, um sie in die Plattform zu integrieren." } } } diff --git a/src/main/webapp/i18n/en/lti.json b/src/main/webapp/i18n/en/lti.json index 37ab48c3c739..1bc0b45cc247 100644 --- a/src/main/webapp/i18n/en/lti.json +++ b/src/main/webapp/i18n/en/lti.json @@ -81,7 +81,9 @@ "linkedSuccessfully": "Linked exercises successfully", "linkedFailed": "Error during deep linking" }, - "missingConfigurationWarning": "Missing values in the LTI1.3 configuration. Launches will not work." + "missingConfigurationWarning": "Missing values in the LTI1.3 configuration. Launches will not work.", + "selectContentFromCourse": "Select content from course {{ title }}", + "selectContentTooltip": "Simply select your preferred exercise and then click the Import button to integrate it into the platform." } } } From 59d00537d421e145d87369ab78589853fabdb68d Mon Sep 17 00:00:00 2001 From: Basak Akan Date: Thu, 19 Oct 2023 20:10:59 +0200 Subject: [PATCH 26/88] fix server test --- .../de/tum/in/www1/artemis/security/lti/Lti13LaunchFilter.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/de/tum/in/www1/artemis/security/lti/Lti13LaunchFilter.java b/src/main/java/de/tum/in/www1/artemis/security/lti/Lti13LaunchFilter.java index a300af773d82..a47b1883a718 100644 --- a/src/main/java/de/tum/in/www1/artemis/security/lti/Lti13LaunchFilter.java +++ b/src/main/java/de/tum/in/www1/artemis/security/lti/Lti13LaunchFilter.java @@ -60,7 +60,7 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse OidcIdToken ltiIdToken = ((OidcUser) authToken.getPrincipal()).getIdToken(); // here we need to check if this is a deep-linking request or a launch request - if (ltiIdToken.getClaim(Claims.MESSAGE_TYPE).equals("LtiDeepLinkingRequest")) { + if (ltiIdToken.getClaim(Claims.MESSAGE_TYPE) != null && ltiIdToken.getClaim(Claims.MESSAGE_TYPE).equals("LtiDeepLinkingRequest")) { lti13Service.startDeepLinking(ltiIdToken, authToken.getAuthorizedClientRegistrationId()); writeResponse(ltiIdToken.getClaim(Claims.TARGET_LINK_URI).toString().replace("api/public/lti13", "lti"), response); From cfd5862ad0feb4f08d928d4367b06e1ac99e6cf0 Mon Sep 17 00:00:00 2001 From: Basak Akan Date: Thu, 19 Oct 2023 21:53:38 +0200 Subject: [PATCH 27/88] add java docs and minor refactoring --- .../security/lti/Lti13LaunchFilter.java | 5 +-- .../service/connectors/lti/Lti13Service.java | 12 ++++- .../connectors/lti/LtiDeepLinkingService.java | 45 ++++++++++++++++++- 3 files changed, 56 insertions(+), 6 deletions(-) diff --git a/src/main/java/de/tum/in/www1/artemis/security/lti/Lti13LaunchFilter.java b/src/main/java/de/tum/in/www1/artemis/security/lti/Lti13LaunchFilter.java index a47b1883a718..2d68ca8b1cd7 100644 --- a/src/main/java/de/tum/in/www1/artemis/security/lti/Lti13LaunchFilter.java +++ b/src/main/java/de/tum/in/www1/artemis/security/lti/Lti13LaunchFilter.java @@ -61,16 +61,13 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse // here we need to check if this is a deep-linking request or a launch request if (ltiIdToken.getClaim(Claims.MESSAGE_TYPE) != null && ltiIdToken.getClaim(Claims.MESSAGE_TYPE).equals("LtiDeepLinkingRequest")) { - lti13Service.startDeepLinking(ltiIdToken, authToken.getAuthorizedClientRegistrationId()); - writeResponse(ltiIdToken.getClaim(Claims.TARGET_LINK_URI).toString().replace("api/public/lti13", "lti"), response); } else { lti13Service.performLaunch(ltiIdToken, authToken.getAuthorizedClientRegistrationId()); - writeResponse(ltiIdToken.getClaim(Claims.TARGET_LINK_URI), response); } - // writeResponse(ltiIdToken.getClaim(Claims.TARGET_LINK_URI), response); + writeResponse(lti13Service.parseTargetLinkUri(ltiIdToken.getClaim(Claims.TARGET_LINK_URI)), response); } catch (HttpClientErrorException | OAuth2AuthenticationException | IllegalStateException ex) { log.error("Error during LTI 1.3 launch request: {}", ex.getMessage()); diff --git a/src/main/java/de/tum/in/www1/artemis/service/connectors/lti/Lti13Service.java b/src/main/java/de/tum/in/www1/artemis/service/connectors/lti/Lti13Service.java index fcdc85efb25c..5df4547ea555 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/connectors/lti/Lti13Service.java +++ b/src/main/java/de/tum/in/www1/artemis/service/connectors/lti/Lti13Service.java @@ -347,7 +347,17 @@ public void startDeepLinking(OidcIdToken ltiIdToken, String clientRegistrationId SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(user.getLogin(), user.getPassword(), Collections.singletonList(new SimpleGrantedAuthority(Role.INSTRUCTOR.getAuthority())))); - ltiDeepLinkingService.populateDeepLinkingResponse(ltiIdToken); + ltiDeepLinkingService.initializeDeepLinkingResponse(ltiIdToken); ltiDeepLinkingService.setupDeepLinkingSettings(ltiIdToken, clientRegistrationId); } + + public String parseTargetLinkUri(String targetLinkUri) { + if (getCourseFromTargetLink(targetLinkUri) == null) { + return targetLinkUri; + } + else { + return ltiDeepLinkingService.buildDeepLinkingTargetLinkUri(getCourseFromTargetLink(targetLinkUri).getId().toString()); + } + } + } diff --git a/src/main/java/de/tum/in/www1/artemis/service/connectors/lti/LtiDeepLinkingService.java b/src/main/java/de/tum/in/www1/artemis/service/connectors/lti/LtiDeepLinkingService.java index 1dd37d0b2714..8da770c35a30 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/connectors/lti/LtiDeepLinkingService.java +++ b/src/main/java/de/tum/in/www1/artemis/service/connectors/lti/LtiDeepLinkingService.java @@ -22,6 +22,9 @@ import de.tum.in.www1.artemis.security.lti.Lti13TokenRetriever; import net.minidev.json.JSONObject; +/** + * Service for handling LTI deep linking functionality. + */ @Service @Profile("lti") public class LtiDeepLinkingService { @@ -43,6 +46,14 @@ public class LtiDeepLinkingService { private String clientRegistrationId; + private final String DEEP_LINKING_TARGET_URL = "/lti/deep-linking/"; + + /** + * Constructor for LtiDeepLinkingService. + * + * @param exerciseRepository The repository for exercises. + * @param tokenRetriever The LTI 1.3 token retriever. + */ public LtiDeepLinkingService(ExerciseRepository exerciseRepository, Lti13TokenRetriever tokenRetriever) { this.exerciseRepository = exerciseRepository; this.tokenRetriever = tokenRetriever; @@ -50,6 +61,11 @@ public LtiDeepLinkingService(ExerciseRepository exerciseRepository, Lti13TokenRe this.deepLinkingSettings = new JSONObject(); } + /** + * Build an LTI deep linking response URL. + * + * @return The LTI deep link response URL. + */ public String buildLtiDeepLinkResponse() { UriComponentsBuilder uriComponentsBuilder = UriComponentsBuilder.fromUriString(this.artemisServerUrl + "/lti/select-content"); Map claims = new HashMap(); @@ -64,13 +80,24 @@ public String buildLtiDeepLinkResponse() { return uriComponentsBuilder.build().toUriString(); } + /** + * Set up deep linking settings. + * + * @param ltiIdToken The LTI 1.3 ID token. + * @param clientRegistrationId The client registration ID. + */ public void setupDeepLinkingSettings(OidcIdToken ltiIdToken, String clientRegistrationId) { this.deepLinkingSettings = new JSONObject(ltiIdToken.getClaim(Claims.DEEP_LINKING_SETTINGS)); this.deploymentId = ltiIdToken.getClaim(Claims.LTI_DEPLOYMENT_ID).toString(); this.clientRegistrationId = clientRegistrationId; } - public void populateDeepLinkingResponse(OidcIdToken ltiIdToken) { + /** + * Initialize the deep linking response with information from OidcIdToken. + * + * @param ltiIdToken The LTI 1.3 ID token. + */ + public void initializeDeepLinkingResponse(OidcIdToken ltiIdToken) { this.deepLinkingResponse.addProperty("aud", ltiIdToken.getClaim("iss").toString()); this.deepLinkingResponse.addProperty("iss", ltiIdToken.getClaim("aud").toString().replace("[", "").replace("]", "")); // "http://localhost:9000/" this.deepLinkingResponse.addProperty("exp", ltiIdToken.getClaim("exp").toString()); @@ -82,6 +109,12 @@ public void populateDeepLinkingResponse(OidcIdToken ltiIdToken) { this.deepLinkingResponse.addProperty(Claims.LTI_VERSION, "1.3.0"); } + /** + * Populate content items for deep linking response. + * + * @param courseId The course ID. + * @param exerciseId The exercise ID. + */ public void populateContentItems(String courseId, String exerciseId) { JsonObject item = setContentItem(courseId, exerciseId); @@ -90,6 +123,16 @@ public void populateContentItems(String courseId, String exerciseId) { this.deepLinkingResponse.addProperty(Claims.CONTENT_ITEMS, contentItems.toString()); } + /** + * Build the deep linking target link URI. + * + * @param courseId The course ID. + * @return The deep linking target link URI. + */ + public String buildDeepLinkingTargetLinkUri(String courseId) { + return artemisServerUrl + DEEP_LINKING_TARGET_URL + courseId; + } + private JsonObject setContentItem(String courseId, String exerciseId) { Optional exerciseOpt = exerciseRepository.findById(Long.valueOf(exerciseId)); From 0f2f9ed2929d93cfb5fbfcfb74838002f6f67607 Mon Sep 17 00:00:00 2001 From: Basak Akan Date: Thu, 19 Oct 2023 22:10:23 +0200 Subject: [PATCH 28/88] rename client components --- src/main/webapp/app/lti/lti.route.ts | 4 +- .../app/lti/lti13-deep-linking.component.html | 82 ++++++++++++- .../app/lti/lti13-deep-linking.component.ts | 110 ++++++++++++++---- .../lti/lti13-select-content.component.html | 82 +------------ .../app/lti/lti13-select-content.component.ts | 110 ++++-------------- 5 files changed, 194 insertions(+), 194 deletions(-) diff --git a/src/main/webapp/app/lti/lti.route.ts b/src/main/webapp/app/lti/lti.route.ts index 33b340e89f38..54614ac6b8da 100644 --- a/src/main/webapp/app/lti/lti.route.ts +++ b/src/main/webapp/app/lti/lti.route.ts @@ -21,14 +21,14 @@ export const ltiLaunchRoutes: Routes = [ }, { path: 'select-content', - component: Lti13DeepLinkingComponent, + component: Lti13SelectContentComponent, data: { pageTitle: 'artemisApp.lti13.deepLinking.title', }, }, { path: 'deep-linking/:courseId', - component: Lti13SelectContentComponent, + component: Lti13DeepLinkingComponent, data: { pageTitle: 'artemisApp.lti13.deepLinking.title', }, diff --git a/src/main/webapp/app/lti/lti13-deep-linking.component.html b/src/main/webapp/app/lti/lti13-deep-linking.component.html index ef296c9d3481..b27620ef770d 100644 --- a/src/main/webapp/app/lti/lti13-deep-linking.component.html +++ b/src/main/webapp/app/lti/lti13-deep-linking.component.html @@ -1,7 +1,77 @@ -

Linking...

-
-
- - -
+
+ + +
diff --git a/src/main/webapp/app/lti/lti13-deep-linking.component.ts b/src/main/webapp/app/lti/lti13-deep-linking.component.ts index 7ad1ef08265f..92152f02f379 100644 --- a/src/main/webapp/app/lti/lti13-deep-linking.component.ts +++ b/src/main/webapp/app/lti/lti13-deep-linking.component.ts @@ -1,5 +1,13 @@ import { Component, OnInit } from '@angular/core'; -import { ActivatedRoute } from '@angular/router'; +import { ActivatedRoute, Router } from '@angular/router'; +import { OnlineCourseConfiguration } from 'app/entities/online-course-configuration.model'; +import { CourseManagementService } from 'app/course/manage/course-management.service'; +import { Exercise } from 'app/entities/exercise.model'; +import { faExclamationTriangle, faPlayCircle, faSort, faWrench } from '@fortawesome/free-solid-svg-icons'; +import { SortService } from 'app/shared/service/sort.service'; +import { HttpClient, HttpParams } from '@angular/common/http'; +import { AccountService } from 'app/core/auth/account.service'; +import { Course } from 'app/entities/course.model'; @Component({ selector: 'jhi-deep-linking', @@ -7,32 +15,92 @@ import { ActivatedRoute } from '@angular/router'; }) export class Lti13DeepLinkingComponent implements OnInit { courseId: number; - registeredSuccessfully: boolean; - jwt: string; - id: string; - actionLink: string; - response: string; + onlineCourseConfiguration: OnlineCourseConfiguration; + exercises: Exercise[]; + selectedExercise: Exercise; + course: Course; + activeTab = 1; - constructor(private route: ActivatedRoute) {} + predicate = 'type'; + reverse = false; + + // Icons + faSort = faSort; + faExclamationTriangle = faExclamationTriangle; + faWrench = faWrench; + faFileImport = faPlayCircle; + constructor( + private route: ActivatedRoute, + private sortService: SortService, + private courseManagementService: CourseManagementService, + private http: HttpClient, + private accountService: AccountService, + private router: Router, + ) {} /** - * perform LTI 13 deep linking + * Gets the configuration for the course encoded in the route and fetches the exercises */ - ngOnInit(): void { - this.route.params.subscribe(() => { - this.jwt = this.route.snapshot.queryParamMap.get('jwt') ?? ''; - this.id = this.route.snapshot.queryParamMap.get('id') ?? ''; - this.actionLink = this.route.snapshot.queryParamMap.get('deepLinkUri') ?? ''; - this.autoSubmitForm(); + ngOnInit() { + this.route.params.subscribe((params) => { + this.courseId = Number(params['courseId']); + this.accountService.identity().then((user) => { + if (user) { + this.courseManagementService.findWithExercises(this.courseId).subscribe((findWithExercisesResult) => { + if (findWithExercisesResult?.body?.exercises) { + this.course = findWithExercisesResult.body; + this.exercises = findWithExercisesResult.body.exercises; + } + }); + } else { + this.redirectUserToLoginThenTargetLink(window.location.href); + } + }); }); } - autoSubmitForm(): void { - const form = document.getElementById('deepLinkingForm') as HTMLFormElement; - form.action = this.actionLink; - console.log(this.actionLink); - (document.getElementById('JWT'))!.value = this.jwt; - (document.getElementById('id'))!.value = this.id; - form.submit(); + redirectUserToLoginThenTargetLink(currentLink: any): void { + // Redirect the user to the login page + this.router.navigate(['/']).then(() => { + // After navigating to the login page, set up a listener for when the user logs in + this.accountService.getAuthenticationState().subscribe((user) => { + if (user) { + window.location.replace(currentLink); + } + }); + }); + } + + sortRows() { + this.sortService.sortByProperty(this.exercises, this.predicate, this.reverse); + } + + toggleExercise(exercise: Exercise) { + this.selectedExercise = exercise; + } + + isExerciseSelected(exercise: Exercise) { + return this.selectedExercise === exercise; + } + + sendDeepLinkRequest() { + if (this.selectedExercise) { + const httpParams = new HttpParams().set('exerciseId', this.selectedExercise.id!); + this.http.post(`api/lti13/deep-linking/${this.courseId}`, null, { observe: 'response', params: httpParams }).subscribe({ + next: (response) => { + if (response.status === 200) { + if (response.body) { + const targetLink = response.body['targetLinkUri']; + window.location.replace(targetLink); + } + } else { + console.log('Unexpected response status:', response.status); + } + }, + error: (error) => { + console.error('An error occurred:', error); + }, + }); + } } } diff --git a/src/main/webapp/app/lti/lti13-select-content.component.html b/src/main/webapp/app/lti/lti13-select-content.component.html index b27620ef770d..ef296c9d3481 100644 --- a/src/main/webapp/app/lti/lti13-select-content.component.html +++ b/src/main/webapp/app/lti/lti13-select-content.component.html @@ -1,77 +1,7 @@ -
- - - +

Linking...

+
+
+ + +
diff --git a/src/main/webapp/app/lti/lti13-select-content.component.ts b/src/main/webapp/app/lti/lti13-select-content.component.ts index b1c971972658..bcb701aacf99 100644 --- a/src/main/webapp/app/lti/lti13-select-content.component.ts +++ b/src/main/webapp/app/lti/lti13-select-content.component.ts @@ -1,13 +1,5 @@ import { Component, OnInit } from '@angular/core'; -import { ActivatedRoute, Router } from '@angular/router'; -import { OnlineCourseConfiguration } from 'app/entities/online-course-configuration.model'; -import { CourseManagementService } from 'app/course/manage/course-management.service'; -import { Exercise } from 'app/entities/exercise.model'; -import { faExclamationTriangle, faPlayCircle, faSort, faWrench } from '@fortawesome/free-solid-svg-icons'; -import { SortService } from 'app/shared/service/sort.service'; -import { HttpClient, HttpParams } from '@angular/common/http'; -import { AccountService } from 'app/core/auth/account.service'; -import { Course } from 'app/entities/course.model'; +import { ActivatedRoute } from '@angular/router'; @Component({ selector: 'jhi-select-exercise', @@ -15,92 +7,32 @@ import { Course } from 'app/entities/course.model'; }) export class Lti13SelectContentComponent implements OnInit { courseId: number; - onlineCourseConfiguration: OnlineCourseConfiguration; - exercises: Exercise[]; - selectedExercise: Exercise; - course: Course; - activeTab = 1; + registeredSuccessfully: boolean; + jwt: string; + id: string; + actionLink: string; + response: string; - predicate = 'type'; - reverse = false; - - // Icons - faSort = faSort; - faExclamationTriangle = faExclamationTriangle; - faWrench = faWrench; - faFileImport = faPlayCircle; - constructor( - private route: ActivatedRoute, - private sortService: SortService, - private courseManagementService: CourseManagementService, - private http: HttpClient, - private accountService: AccountService, - private router: Router, - ) {} + constructor(private route: ActivatedRoute) {} /** - * Gets the configuration for the course encoded in the route and fetches the exercises + * perform LTI 13 deep linking */ - ngOnInit() { - this.route.params.subscribe((params) => { - this.courseId = Number(params['courseId']); - this.accountService.identity().then((user) => { - if (user) { - this.courseManagementService.findWithExercises(this.courseId).subscribe((findWithExercisesResult) => { - if (findWithExercisesResult?.body?.exercises) { - this.course = findWithExercisesResult.body; - this.exercises = findWithExercisesResult.body.exercises; - } - }); - } else { - this.redirectUserToLoginThenTargetLink(window.location.href); - } - }); + ngOnInit(): void { + this.route.params.subscribe(() => { + this.jwt = this.route.snapshot.queryParamMap.get('jwt') ?? ''; + this.id = this.route.snapshot.queryParamMap.get('id') ?? ''; + this.actionLink = this.route.snapshot.queryParamMap.get('deepLinkUri') ?? ''; + this.autoSubmitForm(); }); } - redirectUserToLoginThenTargetLink(currentLink: any): void { - // Redirect the user to the login page - this.router.navigate(['/']).then(() => { - // After navigating to the login page, set up a listener for when the user logs in - this.accountService.getAuthenticationState().subscribe((user) => { - if (user) { - window.location.replace(currentLink); - } - }); - }); - } - - sortRows() { - this.sortService.sortByProperty(this.exercises, this.predicate, this.reverse); - } - - toggleExercise(exercise: Exercise) { - this.selectedExercise = exercise; - } - - isExerciseSelected(exercise: Exercise) { - return this.selectedExercise === exercise; - } - - sendDeepLinkRequest() { - if (this.selectedExercise) { - const httpParams = new HttpParams().set('exerciseId', this.selectedExercise.id!); - this.http.post(`api/lti13/deep-linking/${this.courseId}`, null, { observe: 'response', params: httpParams }).subscribe({ - next: (response) => { - if (response.status === 200) { - if (response.body) { - const targetLink = response.body['targetLinkUri']; - window.location.replace(targetLink); - } - } else { - console.log('Unexpected response status:', response.status); - } - }, - error: (error) => { - console.error('An error occurred:', error); - }, - }); - } + autoSubmitForm(): void { + const form = document.getElementById('deepLinkingForm') as HTMLFormElement; + form.action = this.actionLink; + console.log(this.actionLink); + (document.getElementById('JWT'))!.value = this.jwt; + (document.getElementById('id'))!.value = this.id; + form.submit(); } } From 65449d6d07269b0fe26669c360544f2acde22b23 Mon Sep 17 00:00:00 2001 From: Basak Akan Date: Thu, 19 Oct 2023 22:16:09 +0200 Subject: [PATCH 29/88] cleanup client components --- .../app/lti/lti13-deep-linking.component.ts | 36 +++++++++++++++---- .../app/lti/lti13-select-content.component.ts | 13 +++++-- 2 files changed, 40 insertions(+), 9 deletions(-) diff --git a/src/main/webapp/app/lti/lti13-deep-linking.component.ts b/src/main/webapp/app/lti/lti13-deep-linking.component.ts index 92152f02f379..ddd69c301012 100644 --- a/src/main/webapp/app/lti/lti13-deep-linking.component.ts +++ b/src/main/webapp/app/lti/lti13-deep-linking.component.ts @@ -1,9 +1,8 @@ import { Component, OnInit } from '@angular/core'; import { ActivatedRoute, Router } from '@angular/router'; -import { OnlineCourseConfiguration } from 'app/entities/online-course-configuration.model'; import { CourseManagementService } from 'app/course/manage/course-management.service'; import { Exercise } from 'app/entities/exercise.model'; -import { faExclamationTriangle, faPlayCircle, faSort, faWrench } from '@fortawesome/free-solid-svg-icons'; +import { faExclamationTriangle, faSort, faWrench } from '@fortawesome/free-solid-svg-icons'; import { SortService } from 'app/shared/service/sort.service'; import { HttpClient, HttpParams } from '@angular/common/http'; import { AccountService } from 'app/core/auth/account.service'; @@ -15,11 +14,9 @@ import { Course } from 'app/entities/course.model'; }) export class Lti13DeepLinkingComponent implements OnInit { courseId: number; - onlineCourseConfiguration: OnlineCourseConfiguration; exercises: Exercise[]; selectedExercise: Exercise; course: Course; - activeTab = 1; predicate = 'type'; reverse = false; @@ -28,7 +25,6 @@ export class Lti13DeepLinkingComponent implements OnInit { faSort = faSort; faExclamationTriangle = faExclamationTriangle; faWrench = faWrench; - faFileImport = faPlayCircle; constructor( private route: ActivatedRoute, private sortService: SortService, @@ -39,7 +35,11 @@ export class Lti13DeepLinkingComponent implements OnInit { ) {} /** - * Gets the configuration for the course encoded in the route and fetches the exercises + * Initializes the component. + * - Retrieves the course ID from the route parameters. + * - Fetches the user's identity. + * - Retrieves the course details and exercises. + * - Redirects unauthenticated users to the login page. */ ngOnInit() { this.route.params.subscribe((params) => { @@ -59,6 +59,12 @@ export class Lti13DeepLinkingComponent implements OnInit { }); } + /** + * Redirects the user to the login page and sets up a listener for user login. + * After login, redirects the user back to the original link. + * + * @param currentLink The current URL to return to after login. + */ redirectUserToLoginThenTargetLink(currentLink: any): void { // Redirect the user to the login page this.router.navigate(['/']).then(() => { @@ -71,18 +77,36 @@ export class Lti13DeepLinkingComponent implements OnInit { }); } + /** + * Sorts the list of exercises based on the selected predicate and order. + */ sortRows() { this.sortService.sortByProperty(this.exercises, this.predicate, this.reverse); } + /** + * Toggles the selected exercise. + * + * @param exercise The exercise to toggle. + */ toggleExercise(exercise: Exercise) { this.selectedExercise = exercise; } + /** + * Checks if the given exercise is currently selected. + * + * @param exercise The exercise to check. + * @returns True if the exercise is selected, false otherwise. + */ isExerciseSelected(exercise: Exercise) { return this.selectedExercise === exercise; } + /** + * Sends a deep link request for the selected exercise. + * If an exercise is selected, it sends a POST request to initiate deep linking. + */ sendDeepLinkRequest() { if (this.selectedExercise) { const httpParams = new HttpParams().set('exerciseId', this.selectedExercise.id!); diff --git a/src/main/webapp/app/lti/lti13-select-content.component.ts b/src/main/webapp/app/lti/lti13-select-content.component.ts index bcb701aacf99..a2f04548174b 100644 --- a/src/main/webapp/app/lti/lti13-select-content.component.ts +++ b/src/main/webapp/app/lti/lti13-select-content.component.ts @@ -7,16 +7,17 @@ import { ActivatedRoute } from '@angular/router'; }) export class Lti13SelectContentComponent implements OnInit { courseId: number; - registeredSuccessfully: boolean; jwt: string; id: string; actionLink: string; - response: string; constructor(private route: ActivatedRoute) {} /** - * perform LTI 13 deep linking + * Initializes the component. + * - Retrieves query parameters from the route snapshot. + * - Sets the action link for the form. + * - Automatically submits the form. */ ngOnInit(): void { this.route.params.subscribe(() => { @@ -27,6 +28,12 @@ export class Lti13SelectContentComponent implements OnInit { }); } + /** + * Automatically submits the form. + * - Sets the action link for the form. + * - Sets JWT and ID input fields. + * - Submits the form. + */ autoSubmitForm(): void { const form = document.getElementById('deepLinkingForm') as HTMLFormElement; form.action = this.actionLink; From b0c03aa88295d03df0911bfa6a2516c97f0245bb Mon Sep 17 00:00:00 2001 From: Basak Akan Date: Thu, 19 Oct 2023 22:59:00 +0200 Subject: [PATCH 30/88] fix server test --- .../de/tum/in/www1/artemis/security/Lti13LaunchFilterTest.java | 1 + 1 file changed, 1 insertion(+) diff --git a/src/test/java/de/tum/in/www1/artemis/security/Lti13LaunchFilterTest.java b/src/test/java/de/tum/in/www1/artemis/security/Lti13LaunchFilterTest.java index 51fd2c6e30ca..d3e2ccb9dc6d 100644 --- a/src/test/java/de/tum/in/www1/artemis/security/Lti13LaunchFilterTest.java +++ b/src/test/java/de/tum/in/www1/artemis/security/Lti13LaunchFilterTest.java @@ -120,6 +120,7 @@ void authenticatedLogin() throws Exception { doReturn(CustomLti13Configurer.LTI13_LOGIN_PATH).when(httpRequest).getServletPath(); doReturn(oidcToken).when(defaultFilter).attemptAuthentication(any(), any()); doReturn(responseWriter).when(httpResponse).getWriter(); + doReturn(targetLinkUri).when(lti13Service).parseTargetLinkUri(any()); initValidIdToken(); launchFilter.doFilter(httpRequest, httpResponse, filterChain); From aad277f67cf6f97b3c14af7833194635ee2134d7 Mon Sep 17 00:00:00 2001 From: Basak Akan Date: Thu, 19 Oct 2023 23:01:30 +0200 Subject: [PATCH 31/88] fix server test --- .../de/tum/in/www1/artemis/connectors/Lti13ServiceTest.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/test/java/de/tum/in/www1/artemis/connectors/Lti13ServiceTest.java b/src/test/java/de/tum/in/www1/artemis/connectors/Lti13ServiceTest.java index a0bc9173e47b..5972be1aa6be 100644 --- a/src/test/java/de/tum/in/www1/artemis/connectors/Lti13ServiceTest.java +++ b/src/test/java/de/tum/in/www1/artemis/connectors/Lti13ServiceTest.java @@ -11,7 +11,6 @@ import javax.servlet.http.HttpServletResponse; -import de.tum.in.www1.artemis.service.connectors.lti.LtiDeepLinkingService; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -38,6 +37,7 @@ import de.tum.in.www1.artemis.security.lti.Lti13TokenRetriever; import de.tum.in.www1.artemis.service.OnlineCourseConfigurationService; import de.tum.in.www1.artemis.service.connectors.lti.Lti13Service; +import de.tum.in.www1.artemis.service.connectors.lti.LtiDeepLinkingService; import de.tum.in.www1.artemis.service.connectors.lti.LtiService; import de.tum.in.www1.artemis.web.rest.errors.BadRequestAlertException; import de.tum.in.www1.artemis.web.rest.errors.EntityNotFoundException; From 692794e58d4f32ca1e1b042c1110cf2bd4b8f62f Mon Sep 17 00:00:00 2001 From: Basak Akan Date: Sat, 21 Oct 2023 23:30:37 +0200 Subject: [PATCH 32/88] add client tests --- .../lti13-exercise-launch.component.spec.ts | 64 ++++++++++++++++++- 1 file changed, 61 insertions(+), 3 deletions(-) diff --git a/src/test/javascript/spec/component/lti/lti13-exercise-launch.component.spec.ts b/src/test/javascript/spec/component/lti/lti13-exercise-launch.component.spec.ts index 050240b898b9..06d091829ea5 100644 --- a/src/test/javascript/spec/component/lti/lti13-exercise-launch.component.spec.ts +++ b/src/test/javascript/spec/component/lti/lti13-exercise-launch.component.spec.ts @@ -1,12 +1,14 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing'; import { ArtemisTestModule } from '../../test.module'; import { Lti13ExerciseLaunchComponent } from 'app/lti/lti13-exercise-launch.component'; -import { ActivatedRoute, ActivatedRouteSnapshot, convertToParamMap } from '@angular/router'; +import { ActivatedRoute, ActivatedRouteSnapshot, Router, convertToParamMap } from '@angular/router'; import { HttpClientTestingModule } from '@angular/common/http/testing'; import { HttpClient } from '@angular/common/http'; import { of, throwError } from 'rxjs'; import { LoginService } from 'app/core/login/login.service'; import { AccountService } from 'app/core/auth/account.service'; +import { MockAccountService } from '../../helpers/mocks/service/mock-account.service'; +import { User } from 'app/core/user/user.model'; describe('Lti13ExerciseLaunchComponent', () => { let fixture: ComponentFixture; @@ -15,6 +17,10 @@ describe('Lti13ExerciseLaunchComponent', () => { let http: HttpClient; let loginService: LoginService; let accountService: AccountService; + const mockRouter = { + navigate: jest.fn(() => Promise.resolve(true)), + } as unknown as Router; + const navigateSpy = jest.spyOn(mockRouter, 'navigate'); beforeEach(() => { route = { @@ -28,13 +34,15 @@ describe('Lti13ExerciseLaunchComponent', () => { providers: [ { provide: ActivatedRoute, useValue: route }, { provide: LoginService, useValue: loginService }, - { provide: AccountService, useValue: accountService }, + { provide: AccountService, useClass: MockAccountService }, + { provide: Router, useValue: mockRouter }, ], }) .compileComponents() .then(() => { fixture = TestBed.createComponent(Lti13ExerciseLaunchComponent); comp = fixture.componentInstance; + accountService = TestBed.inject(AccountService); }); http = TestBed.inject(HttpClient); @@ -43,6 +51,7 @@ describe('Lti13ExerciseLaunchComponent', () => { afterEach(() => { window.sessionStorage.clear(); jest.restoreAllMocks(); + navigateSpy.mockClear(); }); it('onInit fail without state', () => { @@ -130,4 +139,53 @@ describe('Lti13ExerciseLaunchComponent', () => { expect(comp.isLaunching).toBeFalse(); }); + + it('should redirect user to login when 401 error occurs', fakeAsync(() => { + jest.spyOn(comp, 'authenticateUserThenRedirect'); + jest.spyOn(comp, 'redirectUserToLoginThenTargetLink'); + const httpStub = jest.spyOn(http, 'post').mockReturnValue( + throwError(() => ({ + status: 401, + headers: { get: () => 'mockTargetLinkUri' }, + error: {}, + })), + ); + const identitySpy = jest.spyOn(accountService, 'identity').mockReturnValue(Promise.resolve(undefined)); + const authStateSpy = jest.spyOn(accountService, 'getAuthenticationState').mockReturnValue(of(undefined)); + + comp.ngOnInit(); + tick(1000); + + expect(httpStub).toHaveBeenCalledWith('api/public/lti13/auth-login', expect.anything(), expect.anything()); + expect(comp.authenticateUserThenRedirect).toHaveBeenCalled(); + expect(identitySpy).toHaveBeenCalled(); + expect(comp.redirectUserToLoginThenTargetLink).toHaveBeenCalled(); + expect(navigateSpy).toHaveBeenCalledWith(['/']); + expect(authStateSpy).toHaveBeenCalled(); + })); + + it('should redirect user to target link when user is already logged in', fakeAsync(() => { + window.location.replace = jest.fn(); + jest.spyOn(comp, 'authenticateUserThenRedirect'); + jest.spyOn(comp, 'redirectUserToTargetLink'); + const loggedInUserUser: User = { id: 3, login: 'lti_user', firstName: 'TestUser', lastName: 'Moodle' } as User; + const httpStub = jest.spyOn(http, 'post').mockReturnValue( + throwError(() => ({ + status: 401, + headers: { get: () => 'mockTargetLinkUri' }, + error: {}, + })), + ); + const identitySpy = jest.spyOn(accountService, 'identity').mockReturnValue(Promise.resolve(loggedInUserUser)); + + comp.ngOnInit(); + tick(1000); + + expect(comp.authenticateUserThenRedirect).toHaveBeenCalled(); + expect(identitySpy).toHaveBeenCalled(); + expect(httpStub).toHaveBeenCalled(); + expect(httpStub).toHaveBeenCalledWith('api/public/lti13/auth-login', expect.anything(), expect.anything()); + expect(navigateSpy).not.toHaveBeenCalled(); + expect(comp.redirectUserToTargetLink).toHaveBeenCalled(); + })); }); From ca61cea01ea990bec976d58d28510f53058492d8 Mon Sep 17 00:00:00 2001 From: Basak Akan Date: Sun, 22 Oct 2023 00:30:50 +0200 Subject: [PATCH 33/88] add one more client test --- .../lti13-exercise-launch.component.spec.ts | 56 ++++++++++++------- 1 file changed, 36 insertions(+), 20 deletions(-) diff --git a/src/test/javascript/spec/component/lti/lti13-exercise-launch.component.spec.ts b/src/test/javascript/spec/component/lti/lti13-exercise-launch.component.spec.ts index 06d091829ea5..d46307a46cd3 100644 --- a/src/test/javascript/spec/component/lti/lti13-exercise-launch.component.spec.ts +++ b/src/test/javascript/spec/component/lti/lti13-exercise-launch.component.spec.ts @@ -125,12 +125,7 @@ describe('Lti13ExerciseLaunchComponent', () => { }); it('onInit launch fails on error', () => { - const httpStub = jest.spyOn(http, 'post').mockReturnValue( - throwError(() => ({ - status: 400, - error: {}, - })), - ); + const httpStub = simulateLtiLaunchError(http, 400); comp.ngOnInit(); @@ -143,13 +138,7 @@ describe('Lti13ExerciseLaunchComponent', () => { it('should redirect user to login when 401 error occurs', fakeAsync(() => { jest.spyOn(comp, 'authenticateUserThenRedirect'); jest.spyOn(comp, 'redirectUserToLoginThenTargetLink'); - const httpStub = jest.spyOn(http, 'post').mockReturnValue( - throwError(() => ({ - status: 401, - headers: { get: () => 'mockTargetLinkUri' }, - error: {}, - })), - ); + const httpStub = simulateLtiLaunchError(http, 401); const identitySpy = jest.spyOn(accountService, 'identity').mockReturnValue(Promise.resolve(undefined)); const authStateSpy = jest.spyOn(accountService, 'getAuthenticationState').mockReturnValue(of(undefined)); @@ -169,13 +158,7 @@ describe('Lti13ExerciseLaunchComponent', () => { jest.spyOn(comp, 'authenticateUserThenRedirect'); jest.spyOn(comp, 'redirectUserToTargetLink'); const loggedInUserUser: User = { id: 3, login: 'lti_user', firstName: 'TestUser', lastName: 'Moodle' } as User; - const httpStub = jest.spyOn(http, 'post').mockReturnValue( - throwError(() => ({ - status: 401, - headers: { get: () => 'mockTargetLinkUri' }, - error: {}, - })), - ); + const httpStub = simulateLtiLaunchError(http, 401); const identitySpy = jest.spyOn(accountService, 'identity').mockReturnValue(Promise.resolve(loggedInUserUser)); comp.ngOnInit(); @@ -188,4 +171,37 @@ describe('Lti13ExerciseLaunchComponent', () => { expect(navigateSpy).not.toHaveBeenCalled(); expect(comp.redirectUserToTargetLink).toHaveBeenCalled(); })); + + it('should redirect user to target link after user logged in', fakeAsync(() => { + window.location.replace = jest.fn(); + jest.spyOn(comp, 'authenticateUserThenRedirect'); + jest.spyOn(comp, 'redirectUserToTargetLink'); + jest.spyOn(comp, 'redirectUserToLoginThenTargetLink'); + const loggedInUserUser: User = { id: 3, login: 'lti_user', firstName: 'TestUser', lastName: 'Moodle' } as User; + const httpStub = simulateLtiLaunchError(http, 401); + const identitySpy = jest.spyOn(accountService, 'identity').mockReturnValue(Promise.resolve(undefined)); + const authStateSpy = jest.spyOn(accountService, 'getAuthenticationState').mockReturnValue(of(loggedInUserUser)); + + comp.ngOnInit(); + tick(1000); + + expect(comp.authenticateUserThenRedirect).toHaveBeenCalled(); + expect(identitySpy).toHaveBeenCalled(); + expect(authStateSpy).toHaveBeenCalled(); + expect(httpStub).toHaveBeenCalled(); + expect(httpStub).toHaveBeenCalledWith('api/public/lti13/auth-login', expect.anything(), expect.anything()); + expect(navigateSpy).toHaveBeenCalled(); + expect(comp.redirectUserToLoginThenTargetLink).toHaveBeenCalled(); + })); + + function simulateLtiLaunchError(http: HttpClient, status: number, headers: any = {}, error = {}) { + const httpStub = jest.spyOn(http, 'post').mockReturnValue( + throwError(() => ({ + status, + headers: { get: () => 'mockTargetLinkUri', ...headers }, + error, + })), + ); + return httpStub; + } }); From 8abf2a7a374388e04d810d28c18678858660889d Mon Sep 17 00:00:00 2001 From: Basak Akan Date: Mon, 23 Oct 2023 10:09:08 +0200 Subject: [PATCH 34/88] try fix autosubmit form method --- .../connectors/lti/LtiDeepLinkingService.java | 16 +++++++++-- .../lti/lti13-select-content.component.html | 6 ---- .../app/lti/lti13-select-content.component.ts | 28 ++++--------------- 3 files changed, 19 insertions(+), 31 deletions(-) diff --git a/src/main/java/de/tum/in/www1/artemis/service/connectors/lti/LtiDeepLinkingService.java b/src/main/java/de/tum/in/www1/artemis/service/connectors/lti/LtiDeepLinkingService.java index 8da770c35a30..1d1cf049d412 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/connectors/lti/LtiDeepLinkingService.java +++ b/src/main/java/de/tum/in/www1/artemis/service/connectors/lti/LtiDeepLinkingService.java @@ -68,14 +68,17 @@ public LtiDeepLinkingService(ExerciseRepository exerciseRepository, Lti13TokenRe */ public String buildLtiDeepLinkResponse() { UriComponentsBuilder uriComponentsBuilder = UriComponentsBuilder.fromUriString(this.artemisServerUrl + "/lti/select-content"); + String returnUrl = this.deepLinkingSettings.getAsString("deep_link_return_url"); + Map claims = new HashMap(); for (var entry : deepLinkingResponse.entrySet()) { claims.put(entry.getKey(), entry.getValue().getAsString()); } String jwt = tokenRetriever.createDeepLinkingJWT(this.clientRegistrationId, claims); - uriComponentsBuilder.queryParam("jwt", jwt); - uriComponentsBuilder.queryParam("id", this.deploymentId); - uriComponentsBuilder.queryParam("deepLinkUri", UriComponent.encode(this.deepLinkingSettings.getAsString("deep_link_return_url"), UriComponent.Type.QUERY_PARAM)); + + String htmlResponse = fillHtmlResponse(returnUrl, jwt); + + uriComponentsBuilder.queryParam("htmlResponse", UriComponent.encode(htmlResponse, UriComponent.Type.QUERY_PARAM)); return uriComponentsBuilder.build().toUriString(); } @@ -147,4 +150,11 @@ private JsonObject createContentItem(String type, String title, String url) { item.addProperty("url", url); return item; } + + private String fillHtmlResponse(String returnUrl, String jwt) { + return "\n" + "\n" + "\n" + "\n" + "\n" + "\n" + " \n" + + "
\n" + " \n" + + " \n" + " \n" + "
\n" + + "\n" + " \n" + "\n" + ""; + } } diff --git a/src/main/webapp/app/lti/lti13-select-content.component.html b/src/main/webapp/app/lti/lti13-select-content.component.html index ef296c9d3481..9f6d1e63f282 100644 --- a/src/main/webapp/app/lti/lti13-select-content.component.html +++ b/src/main/webapp/app/lti/lti13-select-content.component.html @@ -1,7 +1 @@

Linking...

-
-
- - -
-
diff --git a/src/main/webapp/app/lti/lti13-select-content.component.ts b/src/main/webapp/app/lti/lti13-select-content.component.ts index a2f04548174b..49b2bc355b21 100644 --- a/src/main/webapp/app/lti/lti13-select-content.component.ts +++ b/src/main/webapp/app/lti/lti13-select-content.component.ts @@ -15,31 +15,15 @@ export class Lti13SelectContentComponent implements OnInit { /** * Initializes the component. - * - Retrieves query parameters from the route snapshot. - * - Sets the action link for the form. - * - Automatically submits the form. + * - Retrieves html response from the route snapshot. + * - Loads the response. */ ngOnInit(): void { this.route.params.subscribe(() => { - this.jwt = this.route.snapshot.queryParamMap.get('jwt') ?? ''; - this.id = this.route.snapshot.queryParamMap.get('id') ?? ''; - this.actionLink = this.route.snapshot.queryParamMap.get('deepLinkUri') ?? ''; - this.autoSubmitForm(); + const htmlResponse = this.route.snapshot.queryParamMap.get('htmlResponse') ?? ''; + document.open(); + document.write(htmlResponse); + document.close(); }); } - - /** - * Automatically submits the form. - * - Sets the action link for the form. - * - Sets JWT and ID input fields. - * - Submits the form. - */ - autoSubmitForm(): void { - const form = document.getElementById('deepLinkingForm') as HTMLFormElement; - form.action = this.actionLink; - console.log(this.actionLink); - (document.getElementById('JWT'))!.value = this.jwt; - (document.getElementById('id'))!.value = this.id; - form.submit(); - } } From 8afe8e25205ffa791046653ecca5db4abfe02974 Mon Sep 17 00:00:00 2001 From: Basak Akan Date: Mon, 23 Oct 2023 22:02:49 +0200 Subject: [PATCH 35/88] extract Lti13DeepLinkingResponse class --- .../domain/lti/Lti13DeepLinkingResponse.java | 189 ++++++++++++++++++ .../service/connectors/lti/Lti13Service.java | 15 +- .../connectors/lti/LtiDeepLinkingService.java | 58 ++---- 3 files changed, 205 insertions(+), 57 deletions(-) create mode 100644 src/main/java/de/tum/in/www1/artemis/domain/lti/Lti13DeepLinkingResponse.java diff --git a/src/main/java/de/tum/in/www1/artemis/domain/lti/Lti13DeepLinkingResponse.java b/src/main/java/de/tum/in/www1/artemis/domain/lti/Lti13DeepLinkingResponse.java new file mode 100644 index 000000000000..bf3f213ebc03 --- /dev/null +++ b/src/main/java/de/tum/in/www1/artemis/domain/lti/Lti13DeepLinkingResponse.java @@ -0,0 +1,189 @@ +package de.tum.in.www1.artemis.domain.lti; + +import java.util.HashMap; +import java.util.Map; + +import org.springframework.security.oauth2.core.oidc.OidcIdToken; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import net.minidev.json.JSONObject; + +public class Lti13DeepLinkingResponse { + + @JsonProperty("aud") + private String aud; + + @JsonProperty("iss") + private String iss; + + @JsonProperty("exp") + private String exp; + + @JsonProperty("iat") + private String iat; + + @JsonProperty("nonce") + private String nonce; + + @JsonProperty(Claims.MSG) + private String message; + + @JsonProperty(Claims.LTI_DEPLOYMENT_ID) + private String deploymentId; + + @JsonProperty(Claims.MESSAGE_TYPE) + private String messageType; + + @JsonProperty(Claims.LTI_VERSION) + private String ltiVersion; + + @JsonProperty(Claims.CONTENT_ITEMS) + private String contentItems; + + private JSONObject deepLinkingSettings; + + private String clientRegistrationId; + + private String returnUrl; + + public Lti13DeepLinkingResponse() { + } + + public Lti13DeepLinkingResponse(OidcIdToken ltiIdToken, String clientRegistrationId) { + this.deepLinkingSettings = new JSONObject(ltiIdToken.getClaim(Claims.DEEP_LINKING_SETTINGS)); + this.setReturnUrl(this.deepLinkingSettings.getAsString("deep_link_return_url")); + this.clientRegistrationId = clientRegistrationId; + + this.setAud(ltiIdToken.getClaim("iss").toString()); + this.setIss(ltiIdToken.getClaim("aud").toString().replace("[", "").replace("]", "")); + this.setExp(ltiIdToken.getClaim("exp").toString()); + this.setIat(ltiIdToken.getClaim("iat").toString()); + this.setNonce(ltiIdToken.getClaim("nonce").toString()); + this.setMessage("Content successfully linked"); + this.setDeploymentId(ltiIdToken.getClaim(Claims.LTI_DEPLOYMENT_ID).toString()); + this.setMessageType("LtiDeepLinkingResponse"); + this.setLtiVersion("1.3.0"); + } + + public Map getClaims() { + Map claims = new HashMap<>(); + + claims.put("aud", aud); + claims.put("iss", iss); + claims.put("exp", exp); + claims.put("iat", iat); + claims.put("nonce", nonce); + claims.put(Claims.MSG, message); + claims.put(Claims.LTI_DEPLOYMENT_ID, deploymentId); + claims.put(Claims.MESSAGE_TYPE, messageType); + claims.put(Claims.LTI_VERSION, ltiVersion); + claims.put(Claims.CONTENT_ITEMS, contentItems); + + return claims; + } + + public void setAud(String aud) { + this.aud = aud; + } + + public String getIss() { + return iss; + } + + public void setIss(String iss) { + this.iss = iss; + } + + public String getExp() { + return exp; + } + + public void setExp(String exp) { + this.exp = exp; + } + + public String getIat() { + return iat; + } + + public void setIat(String iat) { + this.iat = iat; + } + + public String getNonce() { + return nonce; + } + + public void setNonce(String nonce) { + this.nonce = nonce; + } + + public String getMessage() { + return message; + } + + public void setMessage(String message) { + this.message = message; + } + + public String getDeploymentId() { + return deploymentId; + } + + public void setDeploymentId(String deploymentId) { + this.deploymentId = deploymentId; + } + + public String getMessageType() { + return messageType; + } + + public void setMessageType(String messageType) { + this.messageType = messageType; + } + + public String getLtiVersion() { + return ltiVersion; + } + + public void setLtiVersion(String ltiVersion) { + this.ltiVersion = ltiVersion; + } + + public String getContentItems() { + return contentItems; + } + + public void setContentItems(String contentItems) { + this.contentItems = contentItems; + } + + public JSONObject getDeepLinkingSettings() { + return deepLinkingSettings; + } + + public void setDeepLinkingSettings(JSONObject deepLinkingSettings) { + this.deepLinkingSettings = deepLinkingSettings; + } + + public String getClientRegistrationId() { + return clientRegistrationId; + } + + public void setClientRegistrationId(String clientRegistrationId) { + this.clientRegistrationId = clientRegistrationId; + } + + public String getAud() { + return aud; + } + + public String getReturnUrl() { + return returnUrl; + } + + public void setReturnUrl(String returnUrl) { + this.returnUrl = returnUrl; + } +} diff --git a/src/main/java/de/tum/in/www1/artemis/service/connectors/lti/Lti13Service.java b/src/main/java/de/tum/in/www1/artemis/service/connectors/lti/Lti13Service.java index d5804dc8461c..e094d5f9f69e 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/connectors/lti/Lti13Service.java +++ b/src/main/java/de/tum/in/www1/artemis/service/connectors/lti/Lti13Service.java @@ -3,7 +3,6 @@ import java.net.MalformedURLException; import java.net.URL; import java.util.Collection; -import java.util.Collections; import java.util.Map; import java.util.Optional; import java.util.stream.Collectors; @@ -18,8 +17,6 @@ import org.springframework.http.HttpEntity; import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; -import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; -import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.oauth2.client.registration.ClientRegistration; import org.springframework.security.oauth2.core.oidc.OidcIdToken; @@ -35,7 +32,6 @@ import de.tum.in.www1.artemis.domain.participation.StudentParticipation; import de.tum.in.www1.artemis.repository.*; import de.tum.in.www1.artemis.security.ArtemisAuthenticationProvider; -import de.tum.in.www1.artemis.security.Role; import de.tum.in.www1.artemis.security.lti.Lti13TokenRetriever; import de.tum.in.www1.artemis.service.OnlineCourseConfigurationService; import de.tum.in.www1.artemis.web.rest.errors.BadRequestAlertException; @@ -356,15 +352,10 @@ public void startDeepLinking(OidcIdToken ltiIdToken, String clientRegistrationId throw new BadRequestAlertException("LTI is not configured for this course", "LTI", "ltiNotConfigured"); } - // ltiService.authenticateLtiUser(ltiIdToken.getEmail(), createUsernameFromLaunchRequest(ltiIdToken, onlineCourseConfiguration), ltiIdToken.getGivenName(), - // ltiIdToken.getFamilyName(), onlineCourseConfiguration.isRequireExistingUser()); + ltiDeepLinkingService.initializeDeepLinkingResponse(ltiIdToken, clientRegistrationId); - var user = userRepository.findOneByEmailIgnoreCase(ltiIdToken.getEmail()).orElseThrow(); - SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(user.getLogin(), user.getPassword(), - Collections.singletonList(new SimpleGrantedAuthority(Role.INSTRUCTOR.getAuthority())))); - - ltiDeepLinkingService.initializeDeepLinkingResponse(ltiIdToken); - ltiDeepLinkingService.setupDeepLinkingSettings(ltiIdToken, clientRegistrationId); + ltiService.authenticateLtiUser(ltiIdToken.getEmail(), createUsernameFromLaunchRequest(ltiIdToken, onlineCourseConfiguration), ltiIdToken.getGivenName(), + ltiIdToken.getFamilyName(), onlineCourseConfiguration.isRequireExistingUser()); } public String parseTargetLinkUri(String targetLinkUri) { diff --git a/src/main/java/de/tum/in/www1/artemis/service/connectors/lti/LtiDeepLinkingService.java b/src/main/java/de/tum/in/www1/artemis/service/connectors/lti/LtiDeepLinkingService.java index 1d1cf049d412..172d32509847 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/connectors/lti/LtiDeepLinkingService.java +++ b/src/main/java/de/tum/in/www1/artemis/service/connectors/lti/LtiDeepLinkingService.java @@ -1,7 +1,5 @@ package de.tum.in.www1.artemis.service.connectors.lti; -import java.util.HashMap; -import java.util.Map; import java.util.Optional; import org.glassfish.jersey.uri.UriComponent; @@ -17,10 +15,9 @@ import com.google.gson.JsonObject; import de.tum.in.www1.artemis.domain.Exercise; -import de.tum.in.www1.artemis.domain.lti.Claims; +import de.tum.in.www1.artemis.domain.lti.Lti13DeepLinkingResponse; import de.tum.in.www1.artemis.repository.ExerciseRepository; import de.tum.in.www1.artemis.security.lti.Lti13TokenRetriever; -import net.minidev.json.JSONObject; /** * Service for handling LTI deep linking functionality. @@ -38,16 +35,12 @@ public class LtiDeepLinkingService { private final Lti13TokenRetriever tokenRetriever; - private JsonObject deepLinkingResponse; + private Lti13DeepLinkingResponse lti13DeepLinkingResponse; - private JSONObject deepLinkingSettings; - - private String deploymentId; + private final String DEEP_LINKING_TARGET_URL = "/lti/deep-linking/"; private String clientRegistrationId; - private final String DEEP_LINKING_TARGET_URL = "/lti/deep-linking/"; - /** * Constructor for LtiDeepLinkingService. * @@ -57,8 +50,6 @@ public class LtiDeepLinkingService { public LtiDeepLinkingService(ExerciseRepository exerciseRepository, Lti13TokenRetriever tokenRetriever) { this.exerciseRepository = exerciseRepository; this.tokenRetriever = tokenRetriever; - this.deepLinkingResponse = new JsonObject(); - this.deepLinkingSettings = new JSONObject(); } /** @@ -68,14 +59,9 @@ public LtiDeepLinkingService(ExerciseRepository exerciseRepository, Lti13TokenRe */ public String buildLtiDeepLinkResponse() { UriComponentsBuilder uriComponentsBuilder = UriComponentsBuilder.fromUriString(this.artemisServerUrl + "/lti/select-content"); - String returnUrl = this.deepLinkingSettings.getAsString("deep_link_return_url"); - - Map claims = new HashMap(); - for (var entry : deepLinkingResponse.entrySet()) { - claims.put(entry.getKey(), entry.getValue().getAsString()); - } - String jwt = tokenRetriever.createDeepLinkingJWT(this.clientRegistrationId, claims); + String returnUrl = this.lti13DeepLinkingResponse.getReturnUrl(); + String jwt = tokenRetriever.createDeepLinkingJWT(this.clientRegistrationId, this.lti13DeepLinkingResponse.getClaims()); String htmlResponse = fillHtmlResponse(returnUrl, jwt); uriComponentsBuilder.queryParam("htmlResponse", UriComponent.encode(htmlResponse, UriComponent.Type.QUERY_PARAM)); @@ -84,32 +70,14 @@ public String buildLtiDeepLinkResponse() { } /** - * Set up deep linking settings. + * Initialize the deep linking response with information from OidcIdToken and clientRegistrationId. * * @param ltiIdToken The LTI 1.3 ID token. - * @param clientRegistrationId The client registration ID. + * @param clientRegistrationId The LTI 1.3 client registration id. */ - public void setupDeepLinkingSettings(OidcIdToken ltiIdToken, String clientRegistrationId) { - this.deepLinkingSettings = new JSONObject(ltiIdToken.getClaim(Claims.DEEP_LINKING_SETTINGS)); - this.deploymentId = ltiIdToken.getClaim(Claims.LTI_DEPLOYMENT_ID).toString(); + public void initializeDeepLinkingResponse(OidcIdToken ltiIdToken, String clientRegistrationId) { this.clientRegistrationId = clientRegistrationId; - } - - /** - * Initialize the deep linking response with information from OidcIdToken. - * - * @param ltiIdToken The LTI 1.3 ID token. - */ - public void initializeDeepLinkingResponse(OidcIdToken ltiIdToken) { - this.deepLinkingResponse.addProperty("aud", ltiIdToken.getClaim("iss").toString()); - this.deepLinkingResponse.addProperty("iss", ltiIdToken.getClaim("aud").toString().replace("[", "").replace("]", "")); // "http://localhost:9000/" - this.deepLinkingResponse.addProperty("exp", ltiIdToken.getClaim("exp").toString()); - this.deepLinkingResponse.addProperty("iat", ltiIdToken.getClaim("iat").toString()); - this.deepLinkingResponse.addProperty("nonce", ltiIdToken.getClaim("nonce").toString()); - this.deepLinkingResponse.addProperty(Claims.MSG, "Content successfully linked"); - this.deepLinkingResponse.addProperty(Claims.LTI_DEPLOYMENT_ID, ltiIdToken.getClaim(Claims.LTI_DEPLOYMENT_ID).toString()); - this.deepLinkingResponse.addProperty(Claims.MESSAGE_TYPE, "LtiDeepLinkingResponse"); - this.deepLinkingResponse.addProperty(Claims.LTI_VERSION, "1.3.0"); + this.lti13DeepLinkingResponse = new Lti13DeepLinkingResponse(ltiIdToken, clientRegistrationId); } /** @@ -123,7 +91,7 @@ public void populateContentItems(String courseId, String exerciseId) { JsonArray contentItems = new JsonArray(); contentItems.add(item); - this.deepLinkingResponse.addProperty(Claims.CONTENT_ITEMS, contentItems.toString()); + this.lti13DeepLinkingResponse.setContentItems(contentItems.toString()); } /** @@ -138,7 +106,6 @@ public String buildDeepLinkingTargetLinkUri(String courseId) { private JsonObject setContentItem(String courseId, String exerciseId) { Optional exerciseOpt = exerciseRepository.findById(Long.valueOf(exerciseId)); - String launchUrl = String.format(artemisServerUrl + "/courses/%s/exercises/%s", courseId, exerciseId); return createContentItem(exerciseOpt.get().getType(), exerciseOpt.get().getTitle(), launchUrl); } @@ -154,7 +121,8 @@ private JsonObject createContentItem(String type, String title, String url) { private String fillHtmlResponse(String returnUrl, String jwt) { return "\n" + "\n" + "\n" + "\n" + "\n" + "\n" + " \n" + "
\n" + " \n" - + " \n" + " \n" + "
\n" - + "\n" + " \n" + "\n" + ""; + + " \n" + + " \n" + " \n" + "\n" + " \n" + "\n" + ""; } } From a0f6fc033a048f74f6888b7b7486ffd01819bcb2 Mon Sep 17 00:00:00 2001 From: Basak Akan Date: Thu, 26 Oct 2023 13:16:11 +0200 Subject: [PATCH 36/88] add LtiDeepLinkingService server tests, adapt deeplinking target uri in dynamic registration --- .../config/lti/CustomLti13Configurer.java | 2 + .../domain/lti/Lti13ClientRegistration.java | 2 +- .../security/lti/Lti13LaunchFilter.java | 2 +- .../service/connectors/lti/Lti13Service.java | 10 - .../connectors/lti/LtiDeepLinkingService.java | 55 +++-- .../in/www1/artemis/web/rest/LtiResource.java | 2 +- .../connectors/LtiDeepLinkingServiceTest.java | 202 ++++++++++++++++++ .../security/Lti13LaunchFilterTest.java | 1 - 8 files changed, 250 insertions(+), 26 deletions(-) create mode 100644 src/test/java/de/tum/in/www1/artemis/connectors/LtiDeepLinkingServiceTest.java diff --git a/src/main/java/de/tum/in/www1/artemis/config/lti/CustomLti13Configurer.java b/src/main/java/de/tum/in/www1/artemis/config/lti/CustomLti13Configurer.java index dd4f1de01542..106cd24fac10 100644 --- a/src/main/java/de/tum/in/www1/artemis/config/lti/CustomLti13Configurer.java +++ b/src/main/java/de/tum/in/www1/artemis/config/lti/CustomLti13Configurer.java @@ -34,6 +34,8 @@ public class CustomLti13Configurer extends Lti13Configurer { public static final String LTI13_LOGIN_REDIRECT_PROXY_PATH = LTI13_BASE_PATH + "/auth-callback"; + public static final String LTI13_DEEPLINKING_PATH = "/lti/deep-linking/"; + public CustomLti13Configurer() { super.ltiPath(LTI13_BASE_PATH); super.loginInitiationPath(LOGIN_INITIATION_PATH); diff --git a/src/main/java/de/tum/in/www1/artemis/domain/lti/Lti13ClientRegistration.java b/src/main/java/de/tum/in/www1/artemis/domain/lti/Lti13ClientRegistration.java index 9d01e9966817..05fe84d03eef 100644 --- a/src/main/java/de/tum/in/www1/artemis/domain/lti/Lti13ClientRegistration.java +++ b/src/main/java/de/tum/in/www1/artemis/domain/lti/Lti13ClientRegistration.java @@ -59,7 +59,7 @@ public Lti13ClientRegistration(String serverUrl, Course course, String clientReg toolConfiguration.setDomain(domain); toolConfiguration.setTargetLinkUri(serverUrl + "/courses/" + course.getId()); toolConfiguration.setClaims(Arrays.asList("iss", "email", "sub", "name", "given_name", "family_name")); - Message deepLinkingMessage = new Message("LtiDeepLinkingRequest", serverUrl + CustomLti13Configurer.LTI13_BASE_PATH + "/deep-linking/" + course.getId()); + Message deepLinkingMessage = new Message("LtiDeepLinkingRequest", serverUrl + CustomLti13Configurer.LTI13_DEEPLINKING_PATH + course.getId()); toolConfiguration.setMessages(List.of(deepLinkingMessage)); this.setLti13ToolConfiguration(toolConfiguration); } diff --git a/src/main/java/de/tum/in/www1/artemis/security/lti/Lti13LaunchFilter.java b/src/main/java/de/tum/in/www1/artemis/security/lti/Lti13LaunchFilter.java index 243393c83d44..f874ec1a1aeb 100644 --- a/src/main/java/de/tum/in/www1/artemis/security/lti/Lti13LaunchFilter.java +++ b/src/main/java/de/tum/in/www1/artemis/security/lti/Lti13LaunchFilter.java @@ -62,7 +62,7 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse OidcIdToken ltiIdToken = ((OidcUser) authToken.getPrincipal()).getIdToken(); - targetLink = lti13Service.parseTargetLinkUri(ltiIdToken.getClaim(Claims.TARGET_LINK_URI)); + targetLink = ltiIdToken.getClaim(Claims.TARGET_LINK_URI); // here we need to check if this is a deep-linking request or a launch request if (ltiIdToken.getClaim(Claims.MESSAGE_TYPE) != null && ltiIdToken.getClaim(Claims.MESSAGE_TYPE).equals("LtiDeepLinkingRequest")) { diff --git a/src/main/java/de/tum/in/www1/artemis/service/connectors/lti/Lti13Service.java b/src/main/java/de/tum/in/www1/artemis/service/connectors/lti/Lti13Service.java index e094d5f9f69e..a24fc2829b98 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/connectors/lti/Lti13Service.java +++ b/src/main/java/de/tum/in/www1/artemis/service/connectors/lti/Lti13Service.java @@ -357,14 +357,4 @@ public void startDeepLinking(OidcIdToken ltiIdToken, String clientRegistrationId ltiService.authenticateLtiUser(ltiIdToken.getEmail(), createUsernameFromLaunchRequest(ltiIdToken, onlineCourseConfiguration), ltiIdToken.getGivenName(), ltiIdToken.getFamilyName(), onlineCourseConfiguration.isRequireExistingUser()); } - - public String parseTargetLinkUri(String targetLinkUri) { - if (getCourseFromTargetLink(targetLinkUri) == null) { - return targetLinkUri; - } - else { - return ltiDeepLinkingService.buildDeepLinkingTargetLinkUri(getCourseFromTargetLink(targetLinkUri).getId().toString()); - } - } - } diff --git a/src/main/java/de/tum/in/www1/artemis/service/connectors/lti/LtiDeepLinkingService.java b/src/main/java/de/tum/in/www1/artemis/service/connectors/lti/LtiDeepLinkingService.java index 172d32509847..77f56183720d 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/connectors/lti/LtiDeepLinkingService.java +++ b/src/main/java/de/tum/in/www1/artemis/service/connectors/lti/LtiDeepLinkingService.java @@ -18,6 +18,7 @@ import de.tum.in.www1.artemis.domain.lti.Lti13DeepLinkingResponse; import de.tum.in.www1.artemis.repository.ExerciseRepository; import de.tum.in.www1.artemis.security.lti.Lti13TokenRetriever; +import de.tum.in.www1.artemis.web.rest.errors.BadRequestAlertException; /** * Service for handling LTI deep linking functionality. @@ -37,8 +38,6 @@ public class LtiDeepLinkingService { private Lti13DeepLinkingResponse lti13DeepLinkingResponse; - private final String DEEP_LINKING_TARGET_URL = "/lti/deep-linking/"; - private String clientRegistrationId; /** @@ -60,13 +59,18 @@ public LtiDeepLinkingService(ExerciseRepository exerciseRepository, Lti13TokenRe public String buildLtiDeepLinkResponse() { UriComponentsBuilder uriComponentsBuilder = UriComponentsBuilder.fromUriString(this.artemisServerUrl + "/lti/select-content"); - String returnUrl = this.lti13DeepLinkingResponse.getReturnUrl(); String jwt = tokenRetriever.createDeepLinkingJWT(this.clientRegistrationId, this.lti13DeepLinkingResponse.getClaims()); + String returnUrl = this.lti13DeepLinkingResponse.getReturnUrl(); + + // Validate properties are set to create a response + validateDeepLinkingResponseSettings(returnUrl, jwt); + String htmlResponse = fillHtmlResponse(returnUrl, jwt); uriComponentsBuilder.queryParam("htmlResponse", UriComponent.encode(htmlResponse, UriComponent.Type.QUERY_PARAM)); return uriComponentsBuilder.build().toUriString(); + } /** @@ -87,21 +91,30 @@ public void initializeDeepLinkingResponse(OidcIdToken ltiIdToken, String clientR * @param exerciseId The exercise ID. */ public void populateContentItems(String courseId, String exerciseId) { - JsonObject item = setContentItem(courseId, exerciseId); + if (this.lti13DeepLinkingResponse == null) { + throw new BadRequestAlertException("Deep linking response is not initialized correctly.", "LTI", "deepLinkingResponseInitializeFailed"); + } + JsonObject item = setContentItem(courseId, exerciseId); JsonArray contentItems = new JsonArray(); contentItems.add(item); this.lti13DeepLinkingResponse.setContentItems(contentItems.toString()); } - /** - * Build the deep linking target link URI. - * - * @param courseId The course ID. - * @return The deep linking target link URI. - */ - public String buildDeepLinkingTargetLinkUri(String courseId) { - return artemisServerUrl + DEEP_LINKING_TARGET_URL + courseId; + public String getClientRegistrationId() { + return clientRegistrationId; + } + + public void setClientRegistrationId(String clientRegistrationId) { + this.clientRegistrationId = clientRegistrationId; + } + + public Lti13DeepLinkingResponse getLti13DeepLinkingResponse() { + return lti13DeepLinkingResponse; + } + + public void setLti13DeepLinkingResponse(Lti13DeepLinkingResponse lti13DeepLinkingResponse) { + this.lti13DeepLinkingResponse = lti13DeepLinkingResponse; } private JsonObject setContentItem(String courseId, String exerciseId) { @@ -118,6 +131,20 @@ private JsonObject createContentItem(String type, String title, String url) { return item; } + private void validateDeepLinkingResponseSettings(String returnURL, String jwt) { + if (isEmptyString(jwt)) { + throw new BadRequestAlertException("Deep linking response cannot be created", "LTI", "deepLinkingResponseFailed"); + } + + if (isEmptyString(returnURL)) { + throw new BadRequestAlertException("Cannot find platform return URL", "LTI", "deepLinkReturnURLEmpty"); + } + + if (isEmptyString(this.lti13DeepLinkingResponse.getDeploymentId())) { + throw new BadRequestAlertException("Platform deployment id cannot be empty", "LTI", "deploymentIdEmpty"); + } + } + private String fillHtmlResponse(String returnUrl, String jwt) { return "\n" + "\n" + "\n" + "\n" + "\n" + "\n" + " \n" + "
\n" + " \n" @@ -125,4 +152,8 @@ private String fillHtmlResponse(String returnUrl, String jwt) { + " \n" + "
\n" + "\n" + " \n" + "\n" + ""; } + + boolean isEmptyString(String string) { + return string == null || string.isEmpty(); + } } diff --git a/src/main/java/de/tum/in/www1/artemis/web/rest/LtiResource.java b/src/main/java/de/tum/in/www1/artemis/web/rest/LtiResource.java index fdafde1e9c9c..4ad9b95f996c 100644 --- a/src/main/java/de/tum/in/www1/artemis/web/rest/LtiResource.java +++ b/src/main/java/de/tum/in/www1/artemis/web/rest/LtiResource.java @@ -14,7 +14,7 @@ import net.minidev.json.JSONObject; /** - * REST controller to handle LTI10 launches. + * REST controller to handle LTI13 launches. */ @RestController @RequestMapping("/api") diff --git a/src/test/java/de/tum/in/www1/artemis/connectors/LtiDeepLinkingServiceTest.java b/src/test/java/de/tum/in/www1/artemis/connectors/LtiDeepLinkingServiceTest.java new file mode 100644 index 000000000000..40d30939bffe --- /dev/null +++ b/src/test/java/de/tum/in/www1/artemis/connectors/LtiDeepLinkingServiceTest.java @@ -0,0 +1,202 @@ +package de.tum.in.www1.artemis.connectors; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.mockito.Mockito.*; + +import java.util.Optional; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.oauth2.core.oidc.OidcIdToken; +import org.springframework.test.util.ReflectionTestUtils; + +import com.nimbusds.jose.shaded.json.JSONObject; + +import de.tum.in.www1.artemis.domain.*; +import de.tum.in.www1.artemis.repository.ExerciseRepository; +import de.tum.in.www1.artemis.security.lti.Lti13TokenRetriever; +import de.tum.in.www1.artemis.service.connectors.lti.LtiDeepLinkingService; +import de.tum.in.www1.artemis.web.rest.errors.BadRequestAlertException; +import uk.ac.ox.ctl.lti13.lti.Claims; + +class LtiDeepLinkingServiceTest { + + @Mock + private ExerciseRepository exerciseRepository; + + @Mock + private Lti13TokenRetriever tokenRetriever; + + private LtiDeepLinkingService ltiDeepLinkingService; + + private AutoCloseable closeable; + + private OidcIdToken oidcIdToken; + + @BeforeEach + void setUp() { + closeable = MockitoAnnotations.openMocks(this); + oidcIdToken = mock(OidcIdToken.class); + SecurityContextHolder.clearContext(); + ltiDeepLinkingService = new LtiDeepLinkingService(exerciseRepository, tokenRetriever); + ReflectionTestUtils.setField(ltiDeepLinkingService, "artemisServerUrl", "http://artemis.com"); + } + + @AfterEach + void tearDown() throws Exception { + if (closeable != null) { + closeable.close(); + } + } + + @Test + void testBuildLtiDeepLinkResponse() { + createMockOidcIdToken(); + when(tokenRetriever.createDeepLinkingJWT(anyString(), anyMap())).thenReturn("test_jwt"); + + long exerciseId = 3; + long courseId = 17; + Exercise exercise = createMockExercise(exerciseId, courseId); + when(exerciseRepository.findById(exerciseId)).thenReturn(Optional.of(exercise)); + + ltiDeepLinkingService.initializeDeepLinkingResponse(oidcIdToken, "test_registration_id"); + ltiDeepLinkingService.populateContentItems(String.valueOf(courseId), String.valueOf(exerciseId)); + + String deepLinkResponse = ltiDeepLinkingService.buildLtiDeepLinkResponse(); + + assertThat(deepLinkResponse).isNotNull(); + assertThat(deepLinkResponse).contains("test_jwt"); + } + + @Test + void testEmptyJwtBuildLtiDeepLinkResponse() { + createMockOidcIdToken(); + when(tokenRetriever.createDeepLinkingJWT(anyString(), anyMap())).thenReturn(null); + + long exerciseId = 3; + long courseId = 17; + Exercise exercise = createMockExercise(exerciseId, courseId); + when(exerciseRepository.findById(exerciseId)).thenReturn(Optional.of(exercise)); + + ltiDeepLinkingService.initializeDeepLinkingResponse(oidcIdToken, "test_registration_id"); + ltiDeepLinkingService.populateContentItems(String.valueOf(courseId), String.valueOf(exerciseId)); + + assertThatExceptionOfType(BadRequestAlertException.class).isThrownBy(() -> ltiDeepLinkingService.buildLtiDeepLinkResponse()) + .withMessage("Deep linking response cannot be created") + .matches(exception -> "LTI".equals(exception.getEntityName()) && "deepLinkingResponseFailed".equals(exception.getErrorKey())); + } + + @Test + void testEmptyReturnUrlBuildLtiDeepLinkResponse() { + createMockOidcIdToken(); + when(tokenRetriever.createDeepLinkingJWT(anyString(), anyMap())).thenReturn("test_jwt"); + + long exerciseId = 3; + long courseId = 17; + Exercise exercise = createMockExercise(exerciseId, courseId); + when(exerciseRepository.findById(exerciseId)).thenReturn(Optional.of(exercise)); + + ltiDeepLinkingService.initializeDeepLinkingResponse(oidcIdToken, "test_registration_id"); + ltiDeepLinkingService.populateContentItems(String.valueOf(courseId), String.valueOf(exerciseId)); + ltiDeepLinkingService.getLti13DeepLinkingResponse().setReturnUrl(null); + + assertThatExceptionOfType(BadRequestAlertException.class).isThrownBy(() -> ltiDeepLinkingService.buildLtiDeepLinkResponse()).withMessage("Cannot find platform return URL") + .matches(exception -> "LTI".equals(exception.getEntityName()) && "deepLinkReturnURLEmpty".equals(exception.getErrorKey())); + } + + @Test + void testEmptyDeploymentIdBuildLtiDeepLinkResponse() { + createMockOidcIdToken(); + when(tokenRetriever.createDeepLinkingJWT(anyString(), anyMap())).thenReturn("test_jwt"); + + long exerciseId = 3; + long courseId = 17; + Exercise exercise = createMockExercise(exerciseId, courseId); + when(exerciseRepository.findById(exerciseId)).thenReturn(Optional.of(exercise)); + + ltiDeepLinkingService.initializeDeepLinkingResponse(oidcIdToken, "test_registration_id"); + ltiDeepLinkingService.populateContentItems(String.valueOf(courseId), String.valueOf(exerciseId)); + ltiDeepLinkingService.getLti13DeepLinkingResponse().setDeploymentId(null); + + assertThatExceptionOfType(BadRequestAlertException.class).isThrownBy(() -> ltiDeepLinkingService.buildLtiDeepLinkResponse()) + .withMessage("Platform deployment id cannot be empty") + .matches(exception -> "LTI".equals(exception.getEntityName()) && "deploymentIdEmpty".equals(exception.getErrorKey())); + } + + @Test + void testInitializeDeepLinkingResponse() { + createMockOidcIdToken(); + + ltiDeepLinkingService.initializeDeepLinkingResponse(oidcIdToken, "test_registration_id"); + + assertThat(ltiDeepLinkingService).isNotNull(); + assertThat("test_registration_id").isEqualTo(ltiDeepLinkingService.getClientRegistrationId()); + assertThat(ltiDeepLinkingService.getLti13DeepLinkingResponse()).isNotNull(); + } + + @Test + void testBadRequestForUninitializedDeepLinkingResponseInPopulateContentItems() { + long exerciseId = 3; + long courseId = 14; + Exercise exercise = createMockExercise(exerciseId, courseId); + + when(exerciseRepository.findById(exerciseId)).thenReturn(Optional.of(exercise)); + + assertThatExceptionOfType(BadRequestAlertException.class) + .isThrownBy(() -> ltiDeepLinkingService.populateContentItems(String.valueOf(courseId), String.valueOf(exerciseId))); + + } + + @Test + void testInitializeAndAddContentDeepLinkingResponse() { + createMockOidcIdToken(); + + long exerciseId = 3; + long courseId = 17; + Exercise exercise = createMockExercise(exerciseId, courseId); + + String targetUrl = "courses/" + courseId + "/exercise/" + exerciseId; + when(exerciseRepository.findById(exerciseId)).thenReturn(Optional.of(exercise)); + + ltiDeepLinkingService.initializeDeepLinkingResponse(oidcIdToken, "test_registration_id"); + ltiDeepLinkingService.populateContentItems(String.valueOf(courseId), String.valueOf(exerciseId)); + + assertThat(ltiDeepLinkingService).isNotNull(); + assertThat("test_registration_id").isEqualTo(ltiDeepLinkingService.getClientRegistrationId()); + assertThat(ltiDeepLinkingService.getLti13DeepLinkingResponse()).isNotNull(); + assertThat(ltiDeepLinkingService.getLti13DeepLinkingResponse()).isNotNull(); + assertThat(ltiDeepLinkingService.getLti13DeepLinkingResponse().getContentItems().contains("test_title")); + assertThat(ltiDeepLinkingService.getLti13DeepLinkingResponse().getContentItems().contains(exercise.getType())); + assertThat(ltiDeepLinkingService.getLti13DeepLinkingResponse().getContentItems().contains(targetUrl)); + } + + private void createMockOidcIdToken() { + JSONObject mockSettings = new JSONObject(); + mockSettings.put("deep_link_return_url", "test_return_url"); + when(oidcIdToken.getClaim(Claims.DEEP_LINKING_SETTINGS)).thenReturn(mockSettings); + + when(oidcIdToken.getClaim("iss")).thenReturn("http://artemis.com"); + when(oidcIdToken.getClaim("aud")).thenReturn("http://moodle.com"); + when(oidcIdToken.getClaim("exp")).thenReturn("12345"); + when(oidcIdToken.getClaim("iat")).thenReturn("test"); + when(oidcIdToken.getClaim("nonce")).thenReturn("1234-34535-abcbcbd"); + when(oidcIdToken.getClaim(Claims.LTI_DEPLOYMENT_ID)).thenReturn("1"); + } + + private Exercise createMockExercise(long exerciseId, long courseId) { + Exercise exercise = new TextExercise(); + exercise.setTitle("test_title"); + exercise.setId(exerciseId); + + Course course = new Course(); + course.setId(courseId); + course.setOnlineCourseConfiguration(new OnlineCourseConfiguration()); + exercise.setCourse(course); + return exercise; + } +} diff --git a/src/test/java/de/tum/in/www1/artemis/security/Lti13LaunchFilterTest.java b/src/test/java/de/tum/in/www1/artemis/security/Lti13LaunchFilterTest.java index d3e2ccb9dc6d..51fd2c6e30ca 100644 --- a/src/test/java/de/tum/in/www1/artemis/security/Lti13LaunchFilterTest.java +++ b/src/test/java/de/tum/in/www1/artemis/security/Lti13LaunchFilterTest.java @@ -120,7 +120,6 @@ void authenticatedLogin() throws Exception { doReturn(CustomLti13Configurer.LTI13_LOGIN_PATH).when(httpRequest).getServletPath(); doReturn(oidcToken).when(defaultFilter).attemptAuthentication(any(), any()); doReturn(responseWriter).when(httpResponse).getWriter(); - doReturn(targetLinkUri).when(lti13Service).parseTargetLinkUri(any()); initValidIdToken(); launchFilter.doFilter(httpRequest, httpResponse, filterChain); From eb421b3ccda817759659a35527daccc3aaee8520 Mon Sep 17 00:00:00 2001 From: Basak Akan Date: Thu, 26 Oct 2023 15:00:48 +0200 Subject: [PATCH 37/88] add integration tests --- .../in/www1/artemis/LtiIntegrationTest.java | 28 +++++++++++++ .../security/Lti13LaunchFilterTest.java | 39 +++++++++++++++++++ 2 files changed, 67 insertions(+) diff --git a/src/test/java/de/tum/in/www1/artemis/LtiIntegrationTest.java b/src/test/java/de/tum/in/www1/artemis/LtiIntegrationTest.java index d15b84a2b3c3..b7f2a8b7c283 100644 --- a/src/test/java/de/tum/in/www1/artemis/LtiIntegrationTest.java +++ b/src/test/java/de/tum/in/www1/artemis/LtiIntegrationTest.java @@ -345,6 +345,34 @@ void dynamicRegistrationFailsForNonOnlineCourse() throws Exception { request.postWithoutResponseBody("/api/lti13/dynamic-registration/" + course.getId(), HttpStatus.BAD_REQUEST, params); } + @Test + @WithMockUser(username = TEST_PREFIX + "student1", roles = "USER") + void deepLinkingFailsAsStudent() throws Exception { + var params = new LinkedMultiValueMap(); + params.add("exerciseId", "155"); + + request.postWithoutResponseBody("/api/lti13/deep-linking/" + course.getId(), HttpStatus.FORBIDDEN, params); + } + + @Test + @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") + void deepLinkingFailsWithoutExerciseId() throws Exception { + request.postWithoutResponseBody("/api/lti13/deep-linking/" + course.getId(), HttpStatus.BAD_REQUEST, new LinkedMultiValueMap<>()); + } + + @Test + @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") + void deepLinkingFailsForNonOnlineCourse() throws Exception { + course.setOnlineCourse(false); + course.setOnlineCourseConfiguration(null); + courseRepository.save(course); + + var params = new LinkedMultiValueMap(); + params.add("exerciseId", "155"); + + request.postWithoutResponseBody("/api/lti13/dynamic-registration/" + course.getId(), HttpStatus.BAD_REQUEST, params); + } + private void assertParametersExistingStudent(MultiValueMap parameters) { assertThat(parameters.getFirst("initialize")).isNull(); assertThat(parameters.getFirst("ltiSuccessLoginRequired")).isNotNull(); diff --git a/src/test/java/de/tum/in/www1/artemis/security/Lti13LaunchFilterTest.java b/src/test/java/de/tum/in/www1/artemis/security/Lti13LaunchFilterTest.java index 51fd2c6e30ca..618dc74b3612 100644 --- a/src/test/java/de/tum/in/www1/artemis/security/Lti13LaunchFilterTest.java +++ b/src/test/java/de/tum/in/www1/artemis/security/Lti13LaunchFilterTest.java @@ -114,6 +114,19 @@ private void initValidIdToken() { idTokenClaims.put(Claims.TARGET_LINK_URI, targetLinkUri); } + private void initValidTokenForDeepLinking() { + idTokenClaims.put("iss", "https://some.lms.org"); + idTokenClaims.put("aud", "[962fa4d8-bcbf-49a0-94b2-2de05ad274af]"); + idTokenClaims.put("exp", "1510185728"); + idTokenClaims.put("iat", "1510185228"); + idTokenClaims.put("nonce", "fc5fdc6d-5dd6-47f4-b2c9-5d1216e9b771"); + idTokenClaims.put(Claims.LTI_DEPLOYMENT_ID, "some-deployment-id"); + + idTokenClaims.put(Claims.DEEP_LINKING_SETTINGS, "{ \"deep_link_return_url\": \"https://platform.example/deep_links\" }"); + idTokenClaims.put(Claims.TARGET_LINK_URI, "https://any-artemis-domain.org/api/deep-linking/121"); + idTokenClaims.put(Claims.MESSAGE_TYPE, "LtiDeepLinkingRequest"); + } + @Test void authenticatedLogin() throws Exception { doReturn(true).when(authentication).isAuthenticated(); @@ -136,6 +149,30 @@ void authenticatedLogin() throws Exception { assertThat(((String) responseJsonBody.get("targetLinkUri"))).as("Response body contains the expected targetLinkUri").contains(this.targetLinkUri); } + @Test + void authenticatedLoginForDeepLinking() throws Exception { + doReturn(true).when(authentication).isAuthenticated(); + doReturn(CustomLti13Configurer.LTI13_LOGIN_PATH).when(httpRequest).getServletPath(); + doReturn(oidcToken).when(defaultFilter).attemptAuthentication(any(), any()); + doReturn(responseWriter).when(httpResponse).getWriter(); + initValidTokenForDeepLinking(); + + launchFilter.doFilter(httpRequest, httpResponse, filterChain); + + verify(httpResponse, never()).setStatus(HttpStatus.UNAUTHORIZED.value()); + verify(httpResponse).setContentType("application/json"); + verify(httpResponse).setCharacterEncoding("UTF-8"); + verify(lti13Service).startDeepLinking(any(), any()); + + ArgumentCaptor argument = ArgumentCaptor.forClass(JSONObject.class); + verify(responseWriter).print(argument.capture()); + JSONObject responseJsonBody = argument.getValue(); + verify(lti13Service).buildLtiResponse(any(), any()); + assertThat(((String) responseJsonBody.get("targetLinkUri"))).as("Response body contains the expected targetLinkUri") + .contains("https://any-artemis-domain.org/api/deep-linking/121"); + + } + @Test void authenticatedLogin_oauth2AuthenticationException() throws Exception { doReturn(true).when(authentication).isAuthenticated(); @@ -146,6 +183,7 @@ void authenticatedLogin_oauth2AuthenticationException() throws Exception { verify(httpResponse).sendError(eq(HttpStatus.INTERNAL_SERVER_ERROR.value()), any()); verify(lti13Service, never()).performLaunch(any(), any()); + verify(lti13Service, never()).startDeepLinking(any(), any()); } @Test @@ -158,6 +196,7 @@ void authenticatedLogin_noAuthenticationTokenReturned() throws Exception { verify(httpResponse).sendError(eq(HttpStatus.INTERNAL_SERVER_ERROR.value()), any()); verify(lti13Service, never()).performLaunch(any(), any()); + verify(lti13Service, never()).startDeepLinking(any(), any()); } @Test From 48ac226fc702d1e7a9d63113d75d18ab326f0022 Mon Sep 17 00:00:00 2001 From: Basak Akan Date: Thu, 26 Oct 2023 18:24:51 +0200 Subject: [PATCH 38/88] add server tests for Lti13Service, small path fix --- .../service/connectors/lti/Lti13Service.java | 9 +- .../course-lti-configuration.component.ts | 2 +- .../artemis/connectors/Lti13ServiceTest.java | 86 +++++++++++++++++++ .../security/Lti13LaunchFilterTest.java | 4 +- 4 files changed, 95 insertions(+), 6 deletions(-) diff --git a/src/main/java/de/tum/in/www1/artemis/service/connectors/lti/Lti13Service.java b/src/main/java/de/tum/in/www1/artemis/service/connectors/lti/Lti13Service.java index a24fc2829b98..3ed3fd37766d 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/connectors/lti/Lti13Service.java +++ b/src/main/java/de/tum/in/www1/artemis/service/connectors/lti/Lti13Service.java @@ -43,7 +43,7 @@ public class Lti13Service { private static final String EXERCISE_PATH_PATTERN = "/courses/{courseId}/exercises/{exerciseId}"; - private static final String COURSE_PATH_PATTERN = "/api/public/lti13/deep-linking/{courseId}"; + private static final String COURSE_PATH_PATTERN = "/lti/deep-linking/{courseId}"; private final Logger log = LoggerFactory.getLogger(Lti13Service.class); @@ -344,11 +344,14 @@ public void startDeepLinking(OidcIdToken ltiIdToken, String clientRegistrationId String targetLinkUrl = ltiIdToken.getClaim(Claims.TARGET_LINK_URI); Course targetCourse = getCourseFromTargetLink(targetLinkUrl); + if (targetCourse == null) { + String message = "No course to start deep-linking at " + targetLinkUrl; + log.error(message); + throw new BadRequestAlertException("Course not found", "LTI", "ltiCourseNotFound"); + } OnlineCourseConfiguration onlineCourseConfiguration = targetCourse.getOnlineCourseConfiguration(); if (onlineCourseConfiguration == null) { - String message = "Exercise is not related to course for target link url: " + targetLinkUrl; - log.error(message); throw new BadRequestAlertException("LTI is not configured for this course", "LTI", "ltiNotConfigured"); } diff --git a/src/main/webapp/app/course/manage/course-lti-configuration/course-lti-configuration.component.ts b/src/main/webapp/app/course/manage/course-lti-configuration/course-lti-configuration.component.ts index 47f8b19703e4..ef9e5aadfd5c 100644 --- a/src/main/webapp/app/course/manage/course-lti-configuration/course-lti-configuration.component.ts +++ b/src/main/webapp/app/course/manage/course-lti-configuration/course-lti-configuration.component.ts @@ -72,7 +72,7 @@ export class CourseLtiConfigurationComponent implements OnInit { * Gets the deep linking url */ getDeepLinkingUrl(): string { - return `${location.origin}/api/public/lti13/deep-linking/${this.course.id}`; // Needs to match url in CustomLti13Configurer + return `${location.origin}/lti/deep-linking/${this.course.id}`; // Needs to match url in CustomLti13Configurer } /** diff --git a/src/test/java/de/tum/in/www1/artemis/connectors/Lti13ServiceTest.java b/src/test/java/de/tum/in/www1/artemis/connectors/Lti13ServiceTest.java index bf4cdd24ec2a..3f0a4ba24210 100644 --- a/src/test/java/de/tum/in/www1/artemis/connectors/Lti13ServiceTest.java +++ b/src/test/java/de/tum/in/www1/artemis/connectors/Lti13ServiceTest.java @@ -473,6 +473,92 @@ void onNewResult() throws ParseException { assertThat(launch.getScoreLineItemUrl() + "/scores").as("Score publish request was sent to a wrong URI").isEqualTo(urlCapture.getValue()); } + @Test + void startDeepLinkingCourseFound() { + long exerciseId = 134; + Exercise exercise = new TextExercise(); + exercise.setId(exerciseId); + + long courseId = 12; + Course course = new Course(); + course.setId(courseId); + course.setOnlineCourseConfiguration(new OnlineCourseConfiguration()); + exercise.setCourse(course); + doReturn(Optional.of(exercise)).when(exerciseRepository).findById(exerciseId); + doReturn(course).when(courseRepository).findByIdWithEagerOnlineCourseConfigurationElseThrow(courseId); + + JSONObject mockSettings = new JSONObject(); + mockSettings.put("deep_link_return_url", "test_return_url"); + when(oidcIdToken.getClaim(Claims.DEEP_LINKING_SETTINGS)).thenReturn(mockSettings); + + when(oidcIdToken.getClaim("iss")).thenReturn("http://artemis.com"); + when(oidcIdToken.getClaim("aud")).thenReturn("http://moodle.com"); + when(oidcIdToken.getClaim("exp")).thenReturn("12345"); + when(oidcIdToken.getClaim("iat")).thenReturn("test"); + when(oidcIdToken.getClaim("nonce")).thenReturn("1234-34535-abcbcbd"); + when(oidcIdToken.getClaim(Claims.LTI_DEPLOYMENT_ID)).thenReturn("1"); + when(oidcIdToken.getClaim(Claims.MESSAGE_TYPE)).thenReturn("LtiDeepLinkingRequest"); + when(oidcIdToken.getClaim(Claims.TARGET_LINK_URI)).thenReturn("https://some-artemis-domain.org/lti/deep-linking/" + courseId); + + when(oidcIdToken.getEmail()).thenReturn("testuser@email.com"); + + Optional user = Optional.of(new User()); + doReturn(user).when(userRepository).findOneWithGroupsAndAuthoritiesByLogin(any()); + doNothing().when(ltiService).authenticateLtiUser(any(), any(), any(), any(), anyBoolean()); + doNothing().when(ltiService).onSuccessfulLtiAuthentication(any(), any()); + + lti13Service.startDeepLinking(oidcIdToken, clientRegistrationId); + + verify(ltiDeepLinkingService).initializeDeepLinkingResponse(oidcIdToken, clientRegistrationId); + } + + @Test + void startDeepLinkingNotOnlineCourse() { + long exerciseId = 134; + Exercise exercise = new TextExercise(); + exercise.setId(exerciseId); + + long courseId = 12; + Course course = new Course(); + course.setId(courseId); + exercise.setCourse(course); + doReturn(Optional.of(exercise)).when(exerciseRepository).findById(exerciseId); + doReturn(course).when(courseRepository).findByIdWithEagerOnlineCourseConfigurationElseThrow(courseId); + + OidcIdToken oidcIdToken = mock(OidcIdToken.class); + when(oidcIdToken.getClaim(Claims.TARGET_LINK_URI)).thenReturn("https://some-artemis-domain.org/lti/deep-linking/" + courseId); + + assertThatExceptionOfType(BadRequestAlertException.class).isThrownBy(() -> lti13Service.startDeepLinking(oidcIdToken, clientRegistrationId)); + + } + + @Test + void startDeepLinkingCourseNotFound() { + when(oidcIdToken.getClaim(Claims.TARGET_LINK_URI)).thenReturn("https://some-artemis-domain.org/lti/deep-linking/100000"); + + assertThatExceptionOfType(BadRequestAlertException.class).isThrownBy(() -> lti13Service.startDeepLinking(oidcIdToken, clientRegistrationId)); + + verify(ltiDeepLinkingService, never()).initializeDeepLinkingResponse(any(), any()); + } + + @Test + void startDeepLinkingInvalidPath() { + when(oidcIdToken.getClaim(Claims.TARGET_LINK_URI)).thenReturn("https://some-artemis-domain.org/with/invalid/path/to/deeplinking/11"); + + assertThatExceptionOfType(BadRequestAlertException.class).isThrownBy(() -> lti13Service.startDeepLinking(oidcIdToken, clientRegistrationId)); + + verify(ltiDeepLinkingService, never()).initializeDeepLinkingResponse(any(), any()); + } + + @Test + void startDeepLinkingMalformedUrl() { + doReturn("path").when(oidcIdToken).getClaim(Claims.TARGET_LINK_URI); + + assertThatExceptionOfType(BadRequestAlertException.class).isThrownBy(() -> lti13Service.startDeepLinking(oidcIdToken, clientRegistrationId)); + + verify(ltiDeepLinkingService, never()).initializeDeepLinkingResponse(any(), any()); + } + private State getValidStateForNewResult(Result result) { User user = new User(); user.setLogin("someone"); diff --git a/src/test/java/de/tum/in/www1/artemis/security/Lti13LaunchFilterTest.java b/src/test/java/de/tum/in/www1/artemis/security/Lti13LaunchFilterTest.java index 618dc74b3612..858a26e4af57 100644 --- a/src/test/java/de/tum/in/www1/artemis/security/Lti13LaunchFilterTest.java +++ b/src/test/java/de/tum/in/www1/artemis/security/Lti13LaunchFilterTest.java @@ -123,7 +123,7 @@ private void initValidTokenForDeepLinking() { idTokenClaims.put(Claims.LTI_DEPLOYMENT_ID, "some-deployment-id"); idTokenClaims.put(Claims.DEEP_LINKING_SETTINGS, "{ \"deep_link_return_url\": \"https://platform.example/deep_links\" }"); - idTokenClaims.put(Claims.TARGET_LINK_URI, "https://any-artemis-domain.org/api/deep-linking/121"); + idTokenClaims.put(Claims.TARGET_LINK_URI, "https://any-artemis-domain.org/lti/deep-linking/121"); idTokenClaims.put(Claims.MESSAGE_TYPE, "LtiDeepLinkingRequest"); } @@ -169,7 +169,7 @@ void authenticatedLoginForDeepLinking() throws Exception { JSONObject responseJsonBody = argument.getValue(); verify(lti13Service).buildLtiResponse(any(), any()); assertThat(((String) responseJsonBody.get("targetLinkUri"))).as("Response body contains the expected targetLinkUri") - .contains("https://any-artemis-domain.org/api/deep-linking/121"); + .contains("https://any-artemis-domain.org/lti/deep-linking/121"); } From e1edb92c140de921e728647a49ffb138d4641d79 Mon Sep 17 00:00:00 2001 From: Basak Akan Date: Wed, 1 Nov 2023 22:31:59 +0100 Subject: [PATCH 39/88] use safeResourceUrl pipe --- .../connectors/lti/LtiDeepLinkingService.java | 14 ++---- .../lti/lti13-select-content.component.html | 6 +++ .../app/lti/lti13-select-content.component.ts | 46 ++++++++++++++++--- ...course-lti-configuration.component.spec.ts | 2 +- 4 files changed, 49 insertions(+), 19 deletions(-) diff --git a/src/main/java/de/tum/in/www1/artemis/service/connectors/lti/LtiDeepLinkingService.java b/src/main/java/de/tum/in/www1/artemis/service/connectors/lti/LtiDeepLinkingService.java index 77f56183720d..9ad6fd3a6148 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/connectors/lti/LtiDeepLinkingService.java +++ b/src/main/java/de/tum/in/www1/artemis/service/connectors/lti/LtiDeepLinkingService.java @@ -65,9 +65,9 @@ public String buildLtiDeepLinkResponse() { // Validate properties are set to create a response validateDeepLinkingResponseSettings(returnUrl, jwt); - String htmlResponse = fillHtmlResponse(returnUrl, jwt); - - uriComponentsBuilder.queryParam("htmlResponse", UriComponent.encode(htmlResponse, UriComponent.Type.QUERY_PARAM)); + uriComponentsBuilder.queryParam("jwt", jwt); + uriComponentsBuilder.queryParam("id", this.lti13DeepLinkingResponse.getDeploymentId()); + uriComponentsBuilder.queryParam("deepLinkUri", UriComponent.encode(returnUrl, UriComponent.Type.QUERY_PARAM)); return uriComponentsBuilder.build().toUriString(); @@ -145,14 +145,6 @@ private void validateDeepLinkingResponseSettings(String returnURL, String jwt) { } } - private String fillHtmlResponse(String returnUrl, String jwt) { - return "\n" + "\n" + "\n" + "\n" + "\n" + "\n" + " \n" - + "
\n" + " \n" - + " \n" - + " \n" + "
\n" + "\n" + " \n" + "\n" + ""; - } - boolean isEmptyString(String string) { return string == null || string.isEmpty(); } diff --git a/src/main/webapp/app/lti/lti13-select-content.component.html b/src/main/webapp/app/lti/lti13-select-content.component.html index 9f6d1e63f282..523e3aac39ab 100644 --- a/src/main/webapp/app/lti/lti13-select-content.component.html +++ b/src/main/webapp/app/lti/lti13-select-content.component.html @@ -1 +1,7 @@

Linking...

+
+
+ + +
+
diff --git a/src/main/webapp/app/lti/lti13-select-content.component.ts b/src/main/webapp/app/lti/lti13-select-content.component.ts index 49b2bc355b21..0ea9beed6e9d 100644 --- a/src/main/webapp/app/lti/lti13-select-content.component.ts +++ b/src/main/webapp/app/lti/lti13-select-content.component.ts @@ -1,5 +1,6 @@ import { Component, OnInit } from '@angular/core'; import { ActivatedRoute } from '@angular/router'; +import { FormBuilder, FormGroup } from '@angular/forms'; @Component({ selector: 'jhi-select-exercise', @@ -10,20 +11,51 @@ export class Lti13SelectContentComponent implements OnInit { jwt: string; id: string; actionLink: string; + form: FormGroup; - constructor(private route: ActivatedRoute) {} + constructor( + private route: ActivatedRoute, + private formBuilder: FormBuilder, + ) { + this.form = this.formBuilder.group({ + JWT: [''], + id: [''], + }); + } /** * Initializes the component. - * - Retrieves html response from the route snapshot. - * - Loads the response. + * - Retrieves query parameters from the route snapshot. + * - Sets the action link for the form. + * - Automatically submits the form. */ ngOnInit(): void { this.route.params.subscribe(() => { - const htmlResponse = this.route.snapshot.queryParamMap.get('htmlResponse') ?? ''; - document.open(); - document.write(htmlResponse); - document.close(); + this.updateFormValues(); + this.autoSubmitForm(); }); } + + /** + * Updates the form values with query parameters + * - Retrieves query parameters from the route snapshot. + */ + updateFormValues(): void { + this.actionLink = this.route.snapshot.queryParamMap.get('deepLinkUri') ?? ''; + this.form.patchValue({ + JWT: this.route.snapshot.queryParamMap.get('jwt') ?? '', + id: this.route.snapshot.queryParamMap.get('id') ?? '', + }); + } + + /** + * Automatically submits the form. + * - Sets the action link for the form. + * - Submits the form. + */ + autoSubmitForm(): void { + const form = document.getElementById('deepLinkingForm') as HTMLFormElement; + form.action = this.actionLink; + form.submit(); + } } diff --git a/src/test/javascript/spec/component/course/course-lti-configuration.component.spec.ts b/src/test/javascript/spec/component/course/course-lti-configuration.component.spec.ts index 83bc1e617eb7..c4da0e22adf2 100644 --- a/src/test/javascript/spec/component/course/course-lti-configuration.component.spec.ts +++ b/src/test/javascript/spec/component/course/course-lti-configuration.component.spec.ts @@ -117,7 +117,7 @@ describe('Course LTI Configuration Component', () => { expect(findWithExercisesStub).toHaveBeenCalledOnce(); expect(comp.getDynamicRegistrationUrl()).toBe(`${location.origin}/lti/dynamic-registration/${course.id}`); - expect(comp.getDeepLinkingUrl()).toBe(`${location.origin}/api/public/lti13/deep-linking/${course.id}`); + expect(comp.getDeepLinkingUrl()).toBe(`${location.origin}/lti/deep-linking/${course.id}`); expect(comp.getToolUrl()).toBe(`${location.origin}/courses/${course.id}`); expect(comp.getKeysetUrl()).toBe(`${location.origin}/.well-known/jwks.json`); expect(comp.getInitiateLoginUrl()).toBe(`${location.origin}/api/public/lti13/initiate-login/${course.onlineCourseConfiguration?.registrationId}`); From cdf033c7d8f5d6b7b19bc2dedfd710e1715ac8a2 Mon Sep 17 00:00:00 2001 From: Basak Akan Date: Wed, 1 Nov 2023 22:56:35 +0100 Subject: [PATCH 40/88] fix test --- .../tum/in/www1/artemis/security/Lti13LaunchFilterTest.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/test/java/de/tum/in/www1/artemis/security/Lti13LaunchFilterTest.java b/src/test/java/de/tum/in/www1/artemis/security/Lti13LaunchFilterTest.java index e01798e74e64..87a12487fbd0 100644 --- a/src/test/java/de/tum/in/www1/artemis/security/Lti13LaunchFilterTest.java +++ b/src/test/java/de/tum/in/www1/artemis/security/Lti13LaunchFilterTest.java @@ -165,11 +165,11 @@ void authenticatedLoginForDeepLinking() throws Exception { verify(httpResponse).setCharacterEncoding("UTF-8"); verify(lti13Service).startDeepLinking(any(), any()); - ArgumentCaptor argument = ArgumentCaptor.forClass(JSONObject.class); + ArgumentCaptor argument = ArgumentCaptor.forClass(JsonObject.class); verify(responseWriter).print(argument.capture()); - JSONObject responseJsonBody = argument.getValue(); + JsonObject responseJsonBody = argument.getValue(); verify(lti13Service).buildLtiResponse(any(), any()); - assertThat(((String) responseJsonBody.get("targetLinkUri"))).as("Response body contains the expected targetLinkUri") + assertThat((responseJsonBody.get("targetLinkUri").toString())).as("Response body contains the expected targetLinkUri") .contains("https://any-artemis-domain.org/lti/deep-linking/121"); } From 186d6b0a71160754af3fd7f2c0233709e8d83ae9 Mon Sep 17 00:00:00 2001 From: Basak Akan Date: Fri, 3 Nov 2023 13:07:51 +0100 Subject: [PATCH 41/88] add client tests and small fixes --- .../app/lti/lti13-deep-linking.component.html | 5 +- .../app/lti/lti13-deep-linking.component.ts | 39 +++-- .../lti/lti13-select-content.component.html | 7 +- .../app/lti/lti13-select-content.component.ts | 18 +- src/main/webapp/i18n/de/lti.json | 7 + src/main/webapp/i18n/en/lti.json | 3 +- .../lti/lti13-deep-linking.component.spec.ts | 160 ++++++++++++++++++ 7 files changed, 213 insertions(+), 26 deletions(-) create mode 100644 src/test/javascript/spec/component/lti/lti13-deep-linking.component.spec.ts diff --git a/src/main/webapp/app/lti/lti13-deep-linking.component.html b/src/main/webapp/app/lti/lti13-deep-linking.component.html index b27620ef770d..7a8c69c16df2 100644 --- a/src/main/webapp/app/lti/lti13-deep-linking.component.html +++ b/src/main/webapp/app/lti/lti13-deep-linking.component.html @@ -1,4 +1,4 @@ -
+
+

Error during deep linking

diff --git a/src/main/webapp/app/lti/lti13-deep-linking.component.ts b/src/main/webapp/app/lti/lti13-deep-linking.component.ts index ddd69c301012..96b5b952bb33 100644 --- a/src/main/webapp/app/lti/lti13-deep-linking.component.ts +++ b/src/main/webapp/app/lti/lti13-deep-linking.component.ts @@ -15,18 +15,19 @@ import { Course } from 'app/entities/course.model'; export class Lti13DeepLinkingComponent implements OnInit { courseId: number; exercises: Exercise[]; - selectedExercise: Exercise; + selectedExercise?: Exercise; course: Course; predicate = 'type'; reverse = false; + isLinking = true; // Icons faSort = faSort; faExclamationTriangle = faExclamationTriangle; faWrench = faWrench; constructor( - private route: ActivatedRoute, + public route: ActivatedRoute, private sortService: SortService, private courseManagementService: CourseManagementService, private http: HttpClient, @@ -44,18 +45,26 @@ export class Lti13DeepLinkingComponent implements OnInit { ngOnInit() { this.route.params.subscribe((params) => { this.courseId = Number(params['courseId']); - this.accountService.identity().then((user) => { - if (user) { - this.courseManagementService.findWithExercises(this.courseId).subscribe((findWithExercisesResult) => { - if (findWithExercisesResult?.body?.exercises) { - this.course = findWithExercisesResult.body; - this.exercises = findWithExercisesResult.body.exercises; - } - }); - } else { - this.redirectUserToLoginThenTargetLink(window.location.href); - } - }); + + if (!this.courseId) { + this.isLinking = false; + return; + } + + if (this.isLinking) { + this.accountService.identity().then((user) => { + if (user) { + this.courseManagementService.findWithExercises(this.courseId).subscribe((findWithExercisesResult) => { + if (findWithExercisesResult?.body?.exercises) { + this.course = findWithExercisesResult.body; + this.exercises = findWithExercisesResult.body.exercises; + } + }); + } else { + this.redirectUserToLoginThenTargetLink(window.location.href); + } + }); + } }); } @@ -118,10 +127,12 @@ export class Lti13DeepLinkingComponent implements OnInit { window.location.replace(targetLink); } } else { + this.isLinking = false; console.log('Unexpected response status:', response.status); } }, error: (error) => { + this.isLinking = false; console.error('An error occurred:', error); }, }); diff --git a/src/main/webapp/app/lti/lti13-select-content.component.html b/src/main/webapp/app/lti/lti13-select-content.component.html index 523e3aac39ab..170d70d2509b 100644 --- a/src/main/webapp/app/lti/lti13-select-content.component.html +++ b/src/main/webapp/app/lti/lti13-select-content.component.html @@ -1,7 +1,8 @@ -

Linking...

+

Linking...

- - + +
+

Error during deep linking

diff --git a/src/main/webapp/app/lti/lti13-select-content.component.ts b/src/main/webapp/app/lti/lti13-select-content.component.ts index 0ea9beed6e9d..5210d515914e 100644 --- a/src/main/webapp/app/lti/lti13-select-content.component.ts +++ b/src/main/webapp/app/lti/lti13-select-content.component.ts @@ -7,11 +7,9 @@ import { FormBuilder, FormGroup } from '@angular/forms'; templateUrl: './lti13-select-content.component.html', }) export class Lti13SelectContentComponent implements OnInit { - courseId: number; - jwt: string; - id: string; actionLink: string; form: FormGroup; + isLinking = true; constructor( private route: ActivatedRoute, @@ -32,7 +30,9 @@ export class Lti13SelectContentComponent implements OnInit { ngOnInit(): void { this.route.params.subscribe(() => { this.updateFormValues(); - this.autoSubmitForm(); + if (this.isLinking) { + this.autoSubmitForm(); + } }); } @@ -42,9 +42,15 @@ export class Lti13SelectContentComponent implements OnInit { */ updateFormValues(): void { this.actionLink = this.route.snapshot.queryParamMap.get('deepLinkUri') ?? ''; + const jwt_token = this.route.snapshot.queryParamMap.get('jwt') ?? ''; + const id_token = this.route.snapshot.queryParamMap.get('id') ?? ''; + if (this.actionLink === '' || jwt_token === '' || id_token === '') { + this.isLinking = false; + return; + } this.form.patchValue({ - JWT: this.route.snapshot.queryParamMap.get('jwt') ?? '', - id: this.route.snapshot.queryParamMap.get('id') ?? '', + JWT: jwt_token, + id: id_token, }); } diff --git a/src/main/webapp/i18n/de/lti.json b/src/main/webapp/i18n/de/lti.json index fbfc00975e43..07d84747d28f 100644 --- a/src/main/webapp/i18n/de/lti.json +++ b/src/main/webapp/i18n/de/lti.json @@ -75,6 +75,13 @@ "registeredSuccessfully": "Kurs erfolgreich registriert", "registerFailed": "Fehler bei der dynamischen Registrierung" }, + "deepLinking": { + "title": "Deep Linking", + "linking": "Verlinkung", + "linkedSuccessfully": "Verknüpfte Übungen erfolgreich", + "linkedFailed": "Fehler beim Deep-Linking", + "link": "Verlinken" + }, "missingConfigurationWarning": "Fehlende Werte in der LTI1.3-Konfiguration. Starts werden nicht funktionieren.", "selectContentFromCourse": "Wählen Sie Inhalte aus dem Kurs aus {{ title }}", "selectContentTooltip": "Wählen Sie einfach Ihre bevorzugte Übung aus und klicken Sie dann auf die Schaltfläche Importieren, um sie in die Plattform zu integrieren." diff --git a/src/main/webapp/i18n/en/lti.json b/src/main/webapp/i18n/en/lti.json index 1bc0b45cc247..93797de9b40c 100644 --- a/src/main/webapp/i18n/en/lti.json +++ b/src/main/webapp/i18n/en/lti.json @@ -79,7 +79,8 @@ "title": "Deep Linking", "linking": "Linking", "linkedSuccessfully": "Linked exercises successfully", - "linkedFailed": "Error during deep linking" + "linkedFailed": "Error during deep linking", + "link": "Link" }, "missingConfigurationWarning": "Missing values in the LTI1.3 configuration. Launches will not work.", "selectContentFromCourse": "Select content from course {{ title }}", diff --git a/src/test/javascript/spec/component/lti/lti13-deep-linking.component.spec.ts b/src/test/javascript/spec/component/lti/lti13-deep-linking.component.spec.ts new file mode 100644 index 000000000000..f8c225d76491 --- /dev/null +++ b/src/test/javascript/spec/component/lti/lti13-deep-linking.component.spec.ts @@ -0,0 +1,160 @@ +import { ComponentFixture, TestBed, fakeAsync, tick, waitForAsync } from '@angular/core/testing'; +import { Lti13DeepLinkingComponent } from 'app/lti/lti13-deep-linking.component'; +import { ActivatedRoute, Router } from '@angular/router'; +import { HttpClient, HttpParams, HttpResponse } from '@angular/common/http'; +import { CourseManagementService } from 'app/course/manage/course-management.service'; +import { AccountService } from 'app/core/auth/account.service'; +import { SortService } from 'app/shared/service/sort.service'; +import { of, throwError } from 'rxjs'; +import { ArtemisTranslatePipe } from 'app/shared/pipes/artemis-translate.pipe'; +import { MockPipe } from 'ng-mocks'; +import { User } from 'app/core/user/user.model'; +import { Course } from 'app/entities/course.model'; +import { Exercise, ExerciseType } from 'app/entities/exercise.model'; +import { HelpIconComponent } from 'app/shared/components/help-icon.component'; + +describe('Lti13DeepLinkingComponent', () => { + let component: Lti13DeepLinkingComponent; + let fixture: ComponentFixture; + let activatedRouteMock: any; + + const routerMock = { navigate: jest.fn() }; + const httpMock = { post: jest.fn() }; + const courseManagementServiceMock = { findWithExercises: jest.fn() }; + const accountServiceMock = { identity: jest.fn(), getAuthenticationState: jest.fn() }; + const sortServiceMock = { sortByProperty: jest.fn() }; + + const exercise1 = { id: 1, shortName: 'git', type: ExerciseType.PROGRAMMING } as Exercise; + const exercise2 = { id: 2, shortName: 'test', type: ExerciseType.PROGRAMMING } as Exercise; + const exercise3 = { id: 3, shortName: 'git', type: ExerciseType.MODELING } as Exercise; + const course = { id: 123, shortName: 'tutorial', exercises: [exercise2, exercise1, exercise3] } as Course; + + beforeEach(waitForAsync(() => { + activatedRouteMock = { params: of({ courseId: '123' }) }; + + TestBed.configureTestingModule({ + declarations: [Lti13DeepLinkingComponent, MockPipe(ArtemisTranslatePipe), HelpIconComponent], + providers: [ + { provide: ActivatedRoute, useValue: activatedRouteMock }, + { provide: Router, useValue: routerMock }, + { provide: HttpClient, useValue: httpMock }, + { provide: CourseManagementService, useValue: courseManagementServiceMock }, + { provide: AccountService, useValue: accountServiceMock }, + { provide: SortService, useValue: sortServiceMock }, + ], + }).compileComponents(); + jest.spyOn(console, 'error').mockImplementation(() => {}); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(Lti13DeepLinkingComponent); + component = fixture.componentInstance; + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should retrieve course details and exercises on init when user is authenticated', fakeAsync(() => { + const loggedInUserUser: User = { id: 3, login: 'lti_user', firstName: 'TestUser', lastName: 'Moodle' } as User; + accountServiceMock.identity.mockReturnValue(Promise.resolve(loggedInUserUser)); + courseManagementServiceMock.findWithExercises.mockReturnValue(of(new HttpResponse({ body: course }))); + + component.ngOnInit(); + tick(1000); + + expect(accountServiceMock.identity).toHaveBeenCalled(); + expect(courseManagementServiceMock.findWithExercises).toHaveBeenCalledWith(course.id); + expect(component.courseId).toBe(123); + expect(component.course).toEqual(course); + expect(component.exercises).toContainAllValues(course.exercises!); + })); + + it('should not course details and exercises on init when courseId is empty', fakeAsync(() => { + activatedRouteMock.params = of({}); + fixture = TestBed.createComponent(Lti13DeepLinkingComponent); + component = fixture.componentInstance; + // Manually set the activatedRouteMock to component here + component.route = activatedRouteMock; + + component.ngOnInit(); + tick(1000); + + expect(component.isLinking).toBeFalse(); + expect(accountServiceMock.identity).not.toHaveBeenCalled(); + expect(courseManagementServiceMock.findWithExercises).not.toHaveBeenCalled(); + expect(component.courseId).toBeNaN(); + })); + + it('should not send deep link request when exercise is not selected', () => { + component.selectedExercise = undefined; + + component.sendDeepLinkRequest(); + + expect(httpMock.post).not.toHaveBeenCalled(); + }); + + it('should set isDeepLinking to false if the response status is not 200', fakeAsync(() => { + const replaceMock = jest.fn(); + Object.defineProperty(window, 'location', { + value: { replace: replaceMock }, + writable: true, + }); + component.selectedExercise = exercise1; + component.courseId = 123; + const nonSuccessResponse = new HttpResponse({ + status: 400, + body: { message: 'Bad request' }, + }); + httpMock.post.mockReturnValue(of(nonSuccessResponse)); + + component.sendDeepLinkRequest(); + tick(); + + expect(component.isLinking).toBeFalse(); + expect(httpMock.post).toHaveBeenCalledWith(`api/lti13/deep-linking/${component.courseId}`, null, { + observe: 'response', + params: new HttpParams().set('exerciseId', exercise1.id!), + }); + expect(replaceMock).not.toHaveBeenCalled(); // Verify that we did not navigate + })); + + it('should set isLinking to false if there is an error during the HTTP request', fakeAsync(() => { + component.selectedExercise = exercise1; + component.courseId = 123; + const mockError = new Error('Network error'); + httpMock.post.mockReturnValue(throwError(() => mockError)); + + component.sendDeepLinkRequest(); + tick(); // Simulate time for async operation + + expect(component.isLinking).toBeFalse(); + expect(httpMock.post).toHaveBeenCalledWith(`api/lti13/deep-linking/${component.courseId}`, null, { + observe: 'response', + params: new HttpParams().set('exerciseId', exercise1.id!), + }); + })); + + it('should send deep link request and navigate when exercise is selected', () => { + const replaceMock = jest.fn(); + Object.defineProperty(window, 'location', { + value: { replace: replaceMock }, + writable: true, + }); + component.selectedExercise = exercise1; + component.courseId = 123; + + const mockResponse = new HttpResponse({ + status: 200, + body: { targetLinkUri: 'http://example.com/deep_link' }, + }); + + httpMock.post.mockReturnValue(of(mockResponse)); + component.sendDeepLinkRequest(); + + expect(httpMock.post).toHaveBeenCalledWith(`api/lti13/deep-linking/${component.courseId}`, null, { + observe: 'response', + params: new HttpParams().set('exerciseId', exercise1.id!), + }); + }); +}); From c0287b0596f5ea1fd4ed5dc05752d41e493afdcc Mon Sep 17 00:00:00 2001 From: Basak Akan Date: Fri, 3 Nov 2023 13:25:51 +0100 Subject: [PATCH 42/88] add more client tests --- .../lti13-select-content.component.spec.ts | 84 +++++++++++++++++++ 1 file changed, 84 insertions(+) create mode 100644 src/test/javascript/spec/component/lti/lti13-select-content.component.spec.ts diff --git a/src/test/javascript/spec/component/lti/lti13-select-content.component.spec.ts b/src/test/javascript/spec/component/lti/lti13-select-content.component.spec.ts new file mode 100644 index 000000000000..c8e6f87b73e8 --- /dev/null +++ b/src/test/javascript/spec/component/lti/lti13-select-content.component.spec.ts @@ -0,0 +1,84 @@ +import { Lti13SelectContentComponent } from 'app/lti/lti13-select-content.component'; +import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { ActivatedRoute } from '@angular/router'; +import { FormBuilder, ReactiveFormsModule } from '@angular/forms'; +import { of } from 'rxjs'; +import { ArtemisTranslatePipe } from 'app/shared/pipes/artemis-translate.pipe'; +import { SafeResourceUrlPipe } from 'app/shared/pipes/safe-resource-url.pipe'; +import { MockPipe } from 'ng-mocks'; + +describe('Lti13SelectContentComponent', () => { + let component: Lti13SelectContentComponent; + let fixture: ComponentFixture; + let routeMock: any; + + beforeEach(waitForAsync(() => { + routeMock = { + snapshot: { + queryParamMap: { + get: jest.fn(), + }, + }, + params: of({}), + }; + + TestBed.configureTestingModule({ + imports: [ReactiveFormsModule], + declarations: [Lti13SelectContentComponent, MockPipe(ArtemisTranslatePipe), MockPipe(SafeResourceUrlPipe)], + providers: [FormBuilder, { provide: ActivatedRoute, useValue: routeMock }], + }).compileComponents(); + })); + + beforeEach(() => { + HTMLFormElement.prototype.submit = jest.fn(); + fixture = TestBed.createComponent(Lti13SelectContentComponent); + component = fixture.componentInstance; + }); + + it('should create', () => { + fixture.detectChanges(); + expect(component).toBeTruthy(); + }); + + it('should initialize form on ngOnInit', () => { + const jwt = 'jwt_token'; + const id = 'id_token'; + const deepLinkUri = 'http://example.com/deep_link'; + + routeMock.snapshot.queryParamMap.get.mockImplementation((param: string) => { + switch (param) { + case 'jwt': + return jwt; + case 'id': + return id; + case 'deepLinkUri': + return deepLinkUri; + default: + return null; + } + }); + const autoSubmitSpy = jest.spyOn(component, 'autoSubmitForm'); + + component.ngOnInit(); + + // Check that the form values were set correctly + expect(component.form.value).toEqual({ + JWT: jwt, + id: id, + }); + + expect(component.actionLink).toBe(deepLinkUri); + expect(component.isLinking).toBeTrue(); + expect(autoSubmitSpy).toHaveBeenCalled(); + }); + + it('should not auto-submit form if parameters are missing', () => { + routeMock.snapshot.queryParamMap.get.mockReturnValue(null); + const autoSubmitSpy = jest.spyOn(component, 'autoSubmitForm'); + + component.ngOnInit(); + + expect(component.isLinking).toBeFalse(); + expect(autoSubmitSpy).not.toHaveBeenCalled(); + }); +}); From 5c0a795f3003f3e1db690635269abe26f46ccb07 Mon Sep 17 00:00:00 2001 From: Basak Akan Date: Fri, 3 Nov 2023 16:07:35 +0100 Subject: [PATCH 43/88] fix json implementations --- .../domain/lti/Lti13DeepLinkingResponse.java | 14 +++++++------- .../tum/in/www1/artemis/web/rest/LtiResource.java | 9 +++++---- .../connectors/LtiDeepLinkingServiceTest.java | 6 +++--- 3 files changed, 15 insertions(+), 14 deletions(-) diff --git a/src/main/java/de/tum/in/www1/artemis/domain/lti/Lti13DeepLinkingResponse.java b/src/main/java/de/tum/in/www1/artemis/domain/lti/Lti13DeepLinkingResponse.java index bf3f213ebc03..d748d1a161ba 100644 --- a/src/main/java/de/tum/in/www1/artemis/domain/lti/Lti13DeepLinkingResponse.java +++ b/src/main/java/de/tum/in/www1/artemis/domain/lti/Lti13DeepLinkingResponse.java @@ -6,8 +6,8 @@ import org.springframework.security.oauth2.core.oidc.OidcIdToken; import com.fasterxml.jackson.annotation.JsonProperty; - -import net.minidev.json.JSONObject; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; public class Lti13DeepLinkingResponse { @@ -41,7 +41,7 @@ public class Lti13DeepLinkingResponse { @JsonProperty(Claims.CONTENT_ITEMS) private String contentItems; - private JSONObject deepLinkingSettings; + private JsonObject deepLinkingSettings; private String clientRegistrationId; @@ -51,8 +51,8 @@ public Lti13DeepLinkingResponse() { } public Lti13DeepLinkingResponse(OidcIdToken ltiIdToken, String clientRegistrationId) { - this.deepLinkingSettings = new JSONObject(ltiIdToken.getClaim(Claims.DEEP_LINKING_SETTINGS)); - this.setReturnUrl(this.deepLinkingSettings.getAsString("deep_link_return_url")); + this.deepLinkingSettings = JsonParser.parseString(ltiIdToken.getClaim(Claims.DEEP_LINKING_SETTINGS).toString()).getAsJsonObject(); + this.setReturnUrl(this.deepLinkingSettings.get("deep_link_return_url").toString()); this.clientRegistrationId = clientRegistrationId; this.setAud(ltiIdToken.getClaim("iss").toString()); @@ -159,11 +159,11 @@ public void setContentItems(String contentItems) { this.contentItems = contentItems; } - public JSONObject getDeepLinkingSettings() { + public JsonObject getDeepLinkingSettings() { return deepLinkingSettings; } - public void setDeepLinkingSettings(JSONObject deepLinkingSettings) { + public void setDeepLinkingSettings(JsonObject deepLinkingSettings) { this.deepLinkingSettings = deepLinkingSettings; } diff --git a/src/main/java/de/tum/in/www1/artemis/web/rest/LtiResource.java b/src/main/java/de/tum/in/www1/artemis/web/rest/LtiResource.java index 4ad9b95f996c..6e4936525e41 100644 --- a/src/main/java/de/tum/in/www1/artemis/web/rest/LtiResource.java +++ b/src/main/java/de/tum/in/www1/artemis/web/rest/LtiResource.java @@ -4,6 +4,8 @@ import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; +import com.google.gson.JsonObject; + import de.tum.in.www1.artemis.domain.Course; import de.tum.in.www1.artemis.repository.CourseRepository; import de.tum.in.www1.artemis.security.Role; @@ -11,7 +13,6 @@ import de.tum.in.www1.artemis.service.AuthorizationCheckService; import de.tum.in.www1.artemis.service.connectors.lti.LtiDeepLinkingService; import de.tum.in.www1.artemis.service.connectors.lti.LtiDynamicRegistrationService; -import net.minidev.json.JSONObject; /** * REST controller to handle LTI13 launches. @@ -55,8 +56,8 @@ public ResponseEntity lti13DynamicRegistration(@PathVariable Long course ltiDeepLinkingService.populateContentItems(String.valueOf(courseId), exerciseId); String targetLink = ltiDeepLinkingService.buildLtiDeepLinkResponse(); - JSONObject json = new JSONObject(); - json.put("targetLinkUri", targetLink); - return ResponseEntity.ok(json.toJSONString()); + JsonObject json = new JsonObject(); + json.addProperty("targetLinkUri", targetLink); + return ResponseEntity.ok(json.toString()); } } diff --git a/src/test/java/de/tum/in/www1/artemis/connectors/LtiDeepLinkingServiceTest.java b/src/test/java/de/tum/in/www1/artemis/connectors/LtiDeepLinkingServiceTest.java index 40d30939bffe..468e3dd2ec40 100644 --- a/src/test/java/de/tum/in/www1/artemis/connectors/LtiDeepLinkingServiceTest.java +++ b/src/test/java/de/tum/in/www1/artemis/connectors/LtiDeepLinkingServiceTest.java @@ -15,7 +15,7 @@ import org.springframework.security.oauth2.core.oidc.OidcIdToken; import org.springframework.test.util.ReflectionTestUtils; -import com.nimbusds.jose.shaded.json.JSONObject; +import com.google.gson.JsonObject; import de.tum.in.www1.artemis.domain.*; import de.tum.in.www1.artemis.repository.ExerciseRepository; @@ -176,8 +176,8 @@ void testInitializeAndAddContentDeepLinkingResponse() { } private void createMockOidcIdToken() { - JSONObject mockSettings = new JSONObject(); - mockSettings.put("deep_link_return_url", "test_return_url"); + JsonObject mockSettings = new JsonObject(); + mockSettings.addProperty("deep_link_return_url", "test_return_url"); when(oidcIdToken.getClaim(Claims.DEEP_LINKING_SETTINGS)).thenReturn(mockSettings); when(oidcIdToken.getClaim("iss")).thenReturn("http://artemis.com"); From 327d108cc9e035705ab8294ff314d676cfc89ae5 Mon Sep 17 00:00:00 2001 From: Basak Akan Date: Fri, 3 Nov 2023 23:48:51 +0100 Subject: [PATCH 44/88] increase test coverage --- .../app/lti/lti13-deep-linking.component.ts | 2 -- .../security/lti/Lti13TokenRetrieverTest.java | 28 +++++++++++++++++++ .../lti/lti13-deep-linking.component.spec.ts | 16 ++++++++++- 3 files changed, 43 insertions(+), 3 deletions(-) diff --git a/src/main/webapp/app/lti/lti13-deep-linking.component.ts b/src/main/webapp/app/lti/lti13-deep-linking.component.ts index 96b5b952bb33..07ced4ac72f6 100644 --- a/src/main/webapp/app/lti/lti13-deep-linking.component.ts +++ b/src/main/webapp/app/lti/lti13-deep-linking.component.ts @@ -75,9 +75,7 @@ export class Lti13DeepLinkingComponent implements OnInit { * @param currentLink The current URL to return to after login. */ redirectUserToLoginThenTargetLink(currentLink: any): void { - // Redirect the user to the login page this.router.navigate(['/']).then(() => { - // After navigating to the login page, set up a listener for when the user logs in this.accountService.getAuthenticationState().subscribe((user) => { if (user) { window.location.replace(currentLink); diff --git a/src/test/java/de/tum/in/www1/artemis/security/lti/Lti13TokenRetrieverTest.java b/src/test/java/de/tum/in/www1/artemis/security/lti/Lti13TokenRetrieverTest.java index 007e45f3fa50..e827adcdb1cc 100644 --- a/src/test/java/de/tum/in/www1/artemis/security/lti/Lti13TokenRetrieverTest.java +++ b/src/test/java/de/tum/in/www1/artemis/security/lti/Lti13TokenRetrieverTest.java @@ -10,6 +10,7 @@ import java.security.NoSuchAlgorithmException; import java.security.PrivateKey; import java.security.interfaces.RSAPublicKey; +import java.text.ParseException; import java.util.HashMap; import java.util.Map; import java.util.Optional; @@ -29,10 +30,13 @@ import org.springframework.web.client.RestTemplate; import com.nimbusds.jose.JOSEException; +import com.nimbusds.jose.JOSEObjectType; import com.nimbusds.jose.JWSAlgorithm; import com.nimbusds.jose.jwk.JWK; import com.nimbusds.jose.jwk.KeyUse; import com.nimbusds.jose.jwk.RSAKey; +import com.nimbusds.jwt.JWTClaimsSet; +import com.nimbusds.jwt.SignedJWT; import de.tum.in.www1.artemis.domain.lti.Scopes; import de.tum.in.www1.artemis.security.OAuth2JWKSService; @@ -162,6 +166,30 @@ void getToken() throws NoSuchAlgorithmException { assertThat(token).isEqualTo("result"); } + @Test + void getJWTToken() throws NoSuchAlgorithmException, ParseException { + JWK jwk = generateKey(); + when(oAuth2JWKSService.getJWK(any())).thenReturn(jwk); + + Map claims = new HashMap<>(); + claims.put("customClaim1", "value1"); + claims.put("customClaim2", "value2"); + + String token = lti13TokenRetriever.createDeepLinkingJWT(clientRegistration.getRegistrationId(), claims); + + verify(oAuth2JWKSService).getJWK(clientRegistration.getRegistrationId()); + assertThat(token).isNotNull(); + + SignedJWT signedJWT = SignedJWT.parse(token); + JWTClaimsSet claimsSet = signedJWT.getJWTClaimsSet(); + + for (Map.Entry entry : claims.entrySet()) { + assertThat(entry.getValue()).isEqualTo(claimsSet.getClaim(entry.getKey())); + } + assertThat(JWSAlgorithm.RS256).isEqualTo(signedJWT.getHeader().getAlgorithm()); + assertThat(JOSEObjectType.JWT).isEqualTo(signedJWT.getHeader().getType()); + } + private JWK generateKey() throws NoSuchAlgorithmException { KeyPair clientKeyPair = KeyPairGenerator.getInstance("RSA").generateKeyPair(); StringKeyGenerator kidGenerator = new Base64StringKeyGenerator(32); diff --git a/src/test/javascript/spec/component/lti/lti13-deep-linking.component.spec.ts b/src/test/javascript/spec/component/lti/lti13-deep-linking.component.spec.ts index f8c225d76491..84268b1aa352 100644 --- a/src/test/javascript/spec/component/lti/lti13-deep-linking.component.spec.ts +++ b/src/test/javascript/spec/component/lti/lti13-deep-linking.component.spec.ts @@ -70,6 +70,20 @@ describe('Lti13DeepLinkingComponent', () => { expect(component.exercises).toContainAllValues(course.exercises!); })); + it('should navigate on init when user is authenticated', fakeAsync(() => { + const redirectSpy = jest.spyOn(component, 'redirectUserToLoginThenTargetLink'); + accountServiceMock.identity.mockResolvedValue(undefined); + routerMock.navigate.mockReturnValue(Promise.resolve({})); + accountServiceMock.getAuthenticationState.mockReturnValue(of()); + + component.ngOnInit(); + tick(); + + expect(redirectSpy).toHaveBeenCalledWith(window.location.href); + expect(routerMock.navigate).toHaveBeenCalledWith(['/']); + expect(component.redirectUserToLoginThenTargetLink).toHaveBeenCalled(); + })); + it('should not course details and exercises on init when courseId is empty', fakeAsync(() => { activatedRouteMock.params = of({}); fixture = TestBed.createComponent(Lti13DeepLinkingComponent); @@ -126,7 +140,7 @@ describe('Lti13DeepLinkingComponent', () => { httpMock.post.mockReturnValue(throwError(() => mockError)); component.sendDeepLinkRequest(); - tick(); // Simulate time for async operation + tick(); expect(component.isLinking).toBeFalse(); expect(httpMock.post).toHaveBeenCalledWith(`api/lti13/deep-linking/${component.courseId}`, null, { From a98df2cfebe07d76956c8cf07b88d65647b29c4b Mon Sep 17 00:00:00 2001 From: Basak Akan Date: Sun, 5 Nov 2023 14:00:10 +0100 Subject: [PATCH 45/88] small fixes --- .../domain/lti/Lti13DeepLinkingResponse.java | 2 +- .../app/lti/lti13-deep-linking.component.html | 4 +-- .../lti/lti13-select-content.component.html | 4 +-- .../app/lti/lti13-select-content.component.ts | 34 +++++++++---------- 4 files changed, 21 insertions(+), 23 deletions(-) diff --git a/src/main/java/de/tum/in/www1/artemis/domain/lti/Lti13DeepLinkingResponse.java b/src/main/java/de/tum/in/www1/artemis/domain/lti/Lti13DeepLinkingResponse.java index d748d1a161ba..dd0e438ff08b 100644 --- a/src/main/java/de/tum/in/www1/artemis/domain/lti/Lti13DeepLinkingResponse.java +++ b/src/main/java/de/tum/in/www1/artemis/domain/lti/Lti13DeepLinkingResponse.java @@ -52,7 +52,7 @@ public Lti13DeepLinkingResponse() { public Lti13DeepLinkingResponse(OidcIdToken ltiIdToken, String clientRegistrationId) { this.deepLinkingSettings = JsonParser.parseString(ltiIdToken.getClaim(Claims.DEEP_LINKING_SETTINGS).toString()).getAsJsonObject(); - this.setReturnUrl(this.deepLinkingSettings.get("deep_link_return_url").toString()); + this.setReturnUrl(this.deepLinkingSettings.get("deep_link_return_url").getAsString()); this.clientRegistrationId = clientRegistrationId; this.setAud(ltiIdToken.getClaim("iss").toString()); diff --git a/src/main/webapp/app/lti/lti13-deep-linking.component.html b/src/main/webapp/app/lti/lti13-deep-linking.component.html index 7a8c69c16df2..16bb3d2cc223 100644 --- a/src/main/webapp/app/lti/lti13-deep-linking.component.html +++ b/src/main/webapp/app/lti/lti13-deep-linking.component.html @@ -1,4 +1,4 @@ -
+
-

Error during deep linking

+

Error during deep linking

diff --git a/src/main/webapp/app/lti/lti13-select-content.component.html b/src/main/webapp/app/lti/lti13-select-content.component.html index 170d70d2509b..2b0e90f1194e 100644 --- a/src/main/webapp/app/lti/lti13-select-content.component.html +++ b/src/main/webapp/app/lti/lti13-select-content.component.html @@ -1,8 +1,8 @@

Linking...

- - + +

Error during deep linking

diff --git a/src/main/webapp/app/lti/lti13-select-content.component.ts b/src/main/webapp/app/lti/lti13-select-content.component.ts index 5210d515914e..00a54447db4c 100644 --- a/src/main/webapp/app/lti/lti13-select-content.component.ts +++ b/src/main/webapp/app/lti/lti13-select-content.component.ts @@ -1,25 +1,21 @@ -import { Component, OnInit } from '@angular/core'; +import { Component, OnInit, SecurityContext } from '@angular/core'; import { ActivatedRoute } from '@angular/router'; -import { FormBuilder, FormGroup } from '@angular/forms'; +import { DomSanitizer } from '@angular/platform-browser'; @Component({ selector: 'jhi-select-exercise', templateUrl: './lti13-select-content.component.html', }) export class Lti13SelectContentComponent implements OnInit { + jwt: string; + id: string; actionLink: string; - form: FormGroup; isLinking = true; constructor( private route: ActivatedRoute, - private formBuilder: FormBuilder, - ) { - this.form = this.formBuilder.group({ - JWT: [''], - id: [''], - }); - } + private sanitizer: DomSanitizer, + ) {} /** * Initializes the component. @@ -41,17 +37,17 @@ export class Lti13SelectContentComponent implements OnInit { * - Retrieves query parameters from the route snapshot. */ updateFormValues(): void { - this.actionLink = this.route.snapshot.queryParamMap.get('deepLinkUri') ?? ''; - const jwt_token = this.route.snapshot.queryParamMap.get('jwt') ?? ''; - const id_token = this.route.snapshot.queryParamMap.get('id') ?? ''; - if (this.actionLink === '' || jwt_token === '' || id_token === '') { + // this.actionLink = this.route.snapshot.queryParamMap.get('deepLinkUri') ?? ''; + + const deepLinkUri = this.route.snapshot.queryParamMap.get('deepLinkUri') ?? ''; + this.actionLink = this.sanitizer.sanitize(SecurityContext.URL, deepLinkUri) || ''; + + this.jwt = this.route.snapshot.queryParamMap.get('jwt') ?? ''; + this.id = this.route.snapshot.queryParamMap.get('id') ?? ''; + if (this.actionLink === '' || this.jwt === '' || this.id === '') { this.isLinking = false; return; } - this.form.patchValue({ - JWT: jwt_token, - id: id_token, - }); } /** @@ -62,6 +58,8 @@ export class Lti13SelectContentComponent implements OnInit { autoSubmitForm(): void { const form = document.getElementById('deepLinkingForm') as HTMLFormElement; form.action = this.actionLink; + (document.getElementById('JWT'))!.value = this.jwt; + (document.getElementById('id'))!.value = this.id; form.submit(); } } From e2f712dd63c99e135af5b3a34ddbe289dafa7bff Mon Sep 17 00:00:00 2001 From: Basak Akan Date: Sun, 5 Nov 2023 23:05:57 +0100 Subject: [PATCH 46/88] fix client test --- .../webapp/app/lti/lti13-select-content.component.html | 4 ++-- .../webapp/app/lti/lti13-select-content.component.ts | 9 ++++++--- .../component/lti/lti13-select-content.component.spec.ts | 6 ------ 3 files changed, 8 insertions(+), 11 deletions(-) diff --git a/src/main/webapp/app/lti/lti13-select-content.component.html b/src/main/webapp/app/lti/lti13-select-content.component.html index 2b0e90f1194e..170d70d2509b 100644 --- a/src/main/webapp/app/lti/lti13-select-content.component.html +++ b/src/main/webapp/app/lti/lti13-select-content.component.html @@ -1,8 +1,8 @@

Linking...

- - + +

Error during deep linking

diff --git a/src/main/webapp/app/lti/lti13-select-content.component.ts b/src/main/webapp/app/lti/lti13-select-content.component.ts index 00a54447db4c..ced28fe1d05a 100644 --- a/src/main/webapp/app/lti/lti13-select-content.component.ts +++ b/src/main/webapp/app/lti/lti13-select-content.component.ts @@ -2,6 +2,12 @@ import { Component, OnInit, SecurityContext } from '@angular/core'; import { ActivatedRoute } from '@angular/router'; import { DomSanitizer } from '@angular/platform-browser'; +/** + * Component responsible for sending deep linking content. + * It reads the necessary parameters from the route, sanitizes return URL, + * and automatically submits a form with the relevant data. + * According to LTI documentation auto submit form must be used. + */ @Component({ selector: 'jhi-select-exercise', templateUrl: './lti13-select-content.component.html', @@ -37,11 +43,8 @@ export class Lti13SelectContentComponent implements OnInit { * - Retrieves query parameters from the route snapshot. */ updateFormValues(): void { - // this.actionLink = this.route.snapshot.queryParamMap.get('deepLinkUri') ?? ''; - const deepLinkUri = this.route.snapshot.queryParamMap.get('deepLinkUri') ?? ''; this.actionLink = this.sanitizer.sanitize(SecurityContext.URL, deepLinkUri) || ''; - this.jwt = this.route.snapshot.queryParamMap.get('jwt') ?? ''; this.id = this.route.snapshot.queryParamMap.get('id') ?? ''; if (this.actionLink === '' || this.jwt === '' || this.id === '') { diff --git a/src/test/javascript/spec/component/lti/lti13-select-content.component.spec.ts b/src/test/javascript/spec/component/lti/lti13-select-content.component.spec.ts index c8e6f87b73e8..247056ddd681 100644 --- a/src/test/javascript/spec/component/lti/lti13-select-content.component.spec.ts +++ b/src/test/javascript/spec/component/lti/lti13-select-content.component.spec.ts @@ -61,12 +61,6 @@ describe('Lti13SelectContentComponent', () => { component.ngOnInit(); - // Check that the form values were set correctly - expect(component.form.value).toEqual({ - JWT: jwt, - id: id, - }); - expect(component.actionLink).toBe(deepLinkUri); expect(component.isLinking).toBeTrue(); expect(autoSubmitSpy).toHaveBeenCalled(); From 90fc81b17d6089a3734e9e6b47078ce81ef920d0 Mon Sep 17 00:00:00 2001 From: Basak Akan Date: Tue, 7 Nov 2023 09:21:59 +0100 Subject: [PATCH 47/88] add javadocs --- .../domain/lti/Lti13DeepLinkingResponse.java | 16 ++++++++++++++++ .../security/lti/Lti13TokenRetriever.java | 11 +++++++++++ .../service/connectors/lti/Lti13Service.java | 7 +++++++ .../in/www1/artemis/web/rest/LtiResource.java | 11 ++++++++++- 4 files changed, 44 insertions(+), 1 deletion(-) diff --git a/src/main/java/de/tum/in/www1/artemis/domain/lti/Lti13DeepLinkingResponse.java b/src/main/java/de/tum/in/www1/artemis/domain/lti/Lti13DeepLinkingResponse.java index dd0e438ff08b..a2189ff6a000 100644 --- a/src/main/java/de/tum/in/www1/artemis/domain/lti/Lti13DeepLinkingResponse.java +++ b/src/main/java/de/tum/in/www1/artemis/domain/lti/Lti13DeepLinkingResponse.java @@ -9,6 +9,11 @@ import com.google.gson.JsonObject; import com.google.gson.JsonParser; +/** + * Represents the LTI 1.3 Deep Linking Response. + * It encapsulates the necessary information to construct a valid deep linking response + * according to the LTI 1.3 specification. + */ public class Lti13DeepLinkingResponse { @JsonProperty("aud") @@ -50,6 +55,12 @@ public class Lti13DeepLinkingResponse { public Lti13DeepLinkingResponse() { } + /** + * Constructs an Lti13DeepLinkingResponse from an OIDC ID token and client registration ID. + * + * @param ltiIdToken the OIDC ID token + * @param clientRegistrationId the client registration ID + */ public Lti13DeepLinkingResponse(OidcIdToken ltiIdToken, String clientRegistrationId) { this.deepLinkingSettings = JsonParser.parseString(ltiIdToken.getClaim(Claims.DEEP_LINKING_SETTINGS).toString()).getAsJsonObject(); this.setReturnUrl(this.deepLinkingSettings.get("deep_link_return_url").getAsString()); @@ -66,6 +77,11 @@ public Lti13DeepLinkingResponse(OidcIdToken ltiIdToken, String clientRegistratio this.setLtiVersion("1.3.0"); } + /** + * Retrieves a map of claims to be included in the ID token. + * + * @return a map of claims + */ public Map getClaims() { Map claims = new HashMap<>(); diff --git a/src/main/java/de/tum/in/www1/artemis/security/lti/Lti13TokenRetriever.java b/src/main/java/de/tum/in/www1/artemis/security/lti/Lti13TokenRetriever.java index 107b611d8576..b1492c356dbd 100644 --- a/src/main/java/de/tum/in/www1/artemis/security/lti/Lti13TokenRetriever.java +++ b/src/main/java/de/tum/in/www1/artemis/security/lti/Lti13TokenRetriever.java @@ -87,6 +87,17 @@ public String getToken(ClientRegistration clientRegistration, String... scopes) } } + /** + * Creates a signed JWT for LTI Deep Linking using a specific client registration ID and a set of custom claims. + * The JWT is signed using the RSA algorithm with SHA-256. + * + * @param clientRegistrationId The client registration ID associated with the JWK to be used for signing the JWT. + * @param customClaims A map of custom claims to be included in the JWT. These claims are additional data + * that the consuming service may require. + * @return A serialized signed JWT as a String. + * @throws IllegalArgumentException If no JWK could be retrieved for the provided client registration ID. + * @throws JOSEException If there is an error creating the RSA key pair or signing the JWT. + */ public String createDeepLinkingJWT(String clientRegistrationId, Map customClaims) { JWK jwk = oAuth2JWKSService.getJWK(clientRegistrationId); diff --git a/src/main/java/de/tum/in/www1/artemis/service/connectors/lti/Lti13Service.java b/src/main/java/de/tum/in/www1/artemis/service/connectors/lti/Lti13Service.java index fa7b84c0adcc..c64afab6d009 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/connectors/lti/Lti13Service.java +++ b/src/main/java/de/tum/in/www1/artemis/service/connectors/lti/Lti13Service.java @@ -341,6 +341,13 @@ public void buildLtiResponse(UriComponentsBuilder uriComponentsBuilder, HttpServ ltiService.buildLtiResponse(uriComponentsBuilder, response); } + /** + * Initiates the deep linking process for a course based on the provided LTI ID token and client registration ID. + * + * @param ltiIdToken The ID token containing the deep linking information. + * @param clientRegistrationId The client registration ID of the LTI platform. + * @throws BadRequestAlertException if the course is not found or LTI is not configured for the course. + */ public void startDeepLinking(OidcIdToken ltiIdToken, String clientRegistrationId) { String targetLinkUrl = ltiIdToken.getClaim(Claims.TARGET_LINK_URI); diff --git a/src/main/java/de/tum/in/www1/artemis/web/rest/LtiResource.java b/src/main/java/de/tum/in/www1/artemis/web/rest/LtiResource.java index 6e4936525e41..f69c2d376533 100644 --- a/src/main/java/de/tum/in/www1/artemis/web/rest/LtiResource.java +++ b/src/main/java/de/tum/in/www1/artemis/web/rest/LtiResource.java @@ -50,9 +50,18 @@ public void lti13DynamicRegistration(@PathVariable Long courseId, @RequestParam( ltiDynamicRegistrationService.performDynamicRegistration(course, openIdConfiguration, registrationToken); } + /** + * Handles the HTTP POST request for LTI 1.3 Deep Linking. This endpoint is used for deep linking of LTI links + * for exercises within a course. The method populates content items with the provided course and exercise identifiers, + * builds a deep linking response, and returns the target link URI in a JSON object. + * + * @param courseId The identifier of the course for which the deep linking is being performed. + * @param exerciseId The identifier of the exercise to be included in the deep linking response. + * @return A ResponseEntity containing a JSON object with the 'targetLinkUri' property set to the deep linking response target link. + */ @PostMapping("/lti13/deep-linking/{courseId}") @EnforceAtLeastInstructor - public ResponseEntity lti13DynamicRegistration(@PathVariable Long courseId, @RequestParam(name = "exerciseId") String exerciseId) { + public ResponseEntity lti13DeepLinking(@PathVariable Long courseId, @RequestParam(name = "exerciseId") String exerciseId) { ltiDeepLinkingService.populateContentItems(String.valueOf(courseId), exerciseId); String targetLink = ltiDeepLinkingService.buildLtiDeepLinkResponse(); From f900bf02eb96afb389b7bc329415e7fc3b578054 Mon Sep 17 00:00:00 2001 From: Basak Akan Date: Thu, 16 Nov 2023 17:06:56 +0100 Subject: [PATCH 48/88] refactor LtiDeepLinkingService --- .../domain/lti/Lti13DeepLinkingResponse.java | 27 ++++++ .../security/lti/Lti13LaunchFilter.java | 31 ++++--- .../service/connectors/lti/Lti13Service.java | 5 -- .../connectors/lti/LtiDeepLinkingService.java | 75 +++++++---------- .../in/www1/artemis/web/rest/LtiResource.java | 12 ++- .../app/lti/lti13-deep-linking.component.ts | 6 +- .../lti/lti13-exercise-launch.component.ts | 28 +++++++ .../artemis/connectors/Lti13ServiceTest.java | 10 --- .../connectors/LtiDeepLinkingServiceTest.java | 84 ++++--------------- 9 files changed, 135 insertions(+), 143 deletions(-) diff --git a/src/main/java/de/tum/in/www1/artemis/domain/lti/Lti13DeepLinkingResponse.java b/src/main/java/de/tum/in/www1/artemis/domain/lti/Lti13DeepLinkingResponse.java index a2189ff6a000..2cef96a14012 100644 --- a/src/main/java/de/tum/in/www1/artemis/domain/lti/Lti13DeepLinkingResponse.java +++ b/src/main/java/de/tum/in/www1/artemis/domain/lti/Lti13DeepLinkingResponse.java @@ -62,6 +62,8 @@ public Lti13DeepLinkingResponse() { * @param clientRegistrationId the client registration ID */ public Lti13DeepLinkingResponse(OidcIdToken ltiIdToken, String clientRegistrationId) { + validateClaims(ltiIdToken); + this.deepLinkingSettings = JsonParser.parseString(ltiIdToken.getClaim(Claims.DEEP_LINKING_SETTINGS).toString()).getAsJsonObject(); this.setReturnUrl(this.deepLinkingSettings.get("deep_link_return_url").getAsString()); this.clientRegistrationId = clientRegistrationId; @@ -99,6 +101,31 @@ public Map getClaims() { return claims; } + private void validateClaims(OidcIdToken ltiIdToken) { + if (ltiIdToken == null) { + throw new IllegalArgumentException("The OIDC ID token must not be null."); + } + + Object deepLinkingSettingsElement = ltiIdToken.getClaim(Claims.DEEP_LINKING_SETTINGS); + if (deepLinkingSettingsElement == null) { + throw new IllegalArgumentException("Missing or invalid deep linking settings in ID token."); + } + + ensureClaimPresent(ltiIdToken, "iss"); + ensureClaimPresent(ltiIdToken, "aud"); + ensureClaimPresent(ltiIdToken, "exp"); + ensureClaimPresent(ltiIdToken, "iat"); + ensureClaimPresent(ltiIdToken, "nonce"); + ensureClaimPresent(ltiIdToken, Claims.LTI_DEPLOYMENT_ID); + } + + private void ensureClaimPresent(OidcIdToken ltiIdToken, String claimName) { + Object claimValue = ltiIdToken.getClaim(claimName); + if (claimValue == null) { + throw new IllegalArgumentException("Missing claim: " + claimName); + } + } + public void setAud(String aud) { this.aud = aud; } diff --git a/src/main/java/de/tum/in/www1/artemis/security/lti/Lti13LaunchFilter.java b/src/main/java/de/tum/in/www1/artemis/security/lti/Lti13LaunchFilter.java index bfa9f8a88b14..fce874d9f800 100644 --- a/src/main/java/de/tum/in/www1/artemis/security/lti/Lti13LaunchFilter.java +++ b/src/main/java/de/tum/in/www1/artemis/security/lti/Lti13LaunchFilter.java @@ -56,14 +56,11 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse return; } - // Initialize targetLink as an empty string here to ensure it has a value even if an exception is caught later. - String targetLink = ""; - try { - OidcAuthenticationToken authToken = finishOidcFlow(request, response); - - OidcIdToken ltiIdToken = ((OidcUser) authToken.getPrincipal()).getIdToken(); + OidcAuthenticationToken authToken = finishOidcFlow(request, response); + OidcIdToken ltiIdToken = ((OidcUser) authToken.getPrincipal()).getIdToken(); + String targetLink = ltiIdToken.getClaim(Claims.TARGET_LINK_URI).toString(); - targetLink = ltiIdToken.getClaim(Claims.TARGET_LINK_URI).toString(); + try { // here we need to check if this is a deep-linking request or a launch request if (ltiIdToken.getClaim(Claims.MESSAGE_TYPE) != null && ltiIdToken.getClaim(Claims.MESSAGE_TYPE).equals("LtiDeepLinkingRequest")) { @@ -73,7 +70,7 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse lti13Service.performLaunch(ltiIdToken, authToken.getAuthorizedClientRegistrationId()); } - writeResponse(targetLink, response); + writeResponse(targetLink, ltiIdToken, authToken.getAuthorizedClientRegistrationId(), response); } catch (HttpClientErrorException | OAuth2AuthenticationException | IllegalStateException ex) { log.error("Error during LTI 1.3 launch request: {}", ex.getMessage()); @@ -82,11 +79,18 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse catch (LtiEmailAlreadyInUseException ex) { // LtiEmailAlreadyInUseException is thrown in case of user who has email address in use is not authenticated after targetLink is set // We need targetLink to redirect user on the client-side after successful authentication - response.setHeader("TargetLinkUri", targetLink); - response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "LTI 1.3 user authentication failed"); + handleLtiEmailAlreadyInUseException(response, targetLink, ltiIdToken, authToken); } } + private static void handleLtiEmailAlreadyInUseException(HttpServletResponse response, String targetLink, OidcIdToken ltiIdToken, OidcAuthenticationToken authToken) + throws IOException { + response.setHeader("TargetLinkUri", targetLink); + response.setHeader("ltiIdToken", ltiIdToken.getTokenValue()); + response.setHeader("clientRegistrationId", authToken.getAuthorizedClientRegistrationId()); + response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "LTI 1.3 user authentication failed"); + } + private OidcAuthenticationToken finishOidcFlow(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { OidcAuthenticationToken ltiAuthToken; try { @@ -103,7 +107,7 @@ private OidcAuthenticationToken finishOidcFlow(HttpServletRequest request, HttpS return ltiAuthToken; } - private void writeResponse(String targetLinkUri, HttpServletResponse response) throws IOException { + private void writeResponse(String targetLinkUri, OidcIdToken ltiIdToken, String clientRegistrationId, HttpServletResponse response) throws IOException { PrintWriter writer = response.getWriter(); UriComponentsBuilder uriBuilder = UriComponentsBuilder.fromUriString(targetLinkUri); @@ -112,6 +116,11 @@ private void writeResponse(String targetLinkUri, HttpServletResponse response) t JsonObject json = new JsonObject(); json.addProperty("targetLinkUri", uriBuilder.build().toUriString()); + if (ltiIdToken != null && clientRegistrationId != null) { + json.addProperty("ltiIdToken", ltiIdToken.getTokenValue()); + json.addProperty("clientRegistrationId", clientRegistrationId); + } + response.setContentType("application/json"); response.setCharacterEncoding("UTF-8"); writer.print(json); diff --git a/src/main/java/de/tum/in/www1/artemis/service/connectors/lti/Lti13Service.java b/src/main/java/de/tum/in/www1/artemis/service/connectors/lti/Lti13Service.java index c64afab6d009..ac465ed01597 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/connectors/lti/Lti13Service.java +++ b/src/main/java/de/tum/in/www1/artemis/service/connectors/lti/Lti13Service.java @@ -66,8 +66,6 @@ public class Lti13Service { private final ArtemisAuthenticationProvider artemisAuthenticationProvider; - private final LtiDeepLinkingService ltiDeepLinkingService; - private final RestTemplate restTemplate; public Lti13Service(UserRepository userRepository, ExerciseRepository exerciseRepository, CourseRepository courseRepository, Lti13ResourceLaunchRepository launchRepository, @@ -83,7 +81,6 @@ public Lti13Service(UserRepository userRepository, ExerciseRepository exerciseRe this.onlineCourseConfigurationService = onlineCourseConfigurationService; this.restTemplate = restTemplate; this.artemisAuthenticationProvider = artemisAuthenticationProvider; - this.ltiDeepLinkingService = ltiDeepLinkingService; } /** @@ -363,8 +360,6 @@ public void startDeepLinking(OidcIdToken ltiIdToken, String clientRegistrationId throw new BadRequestAlertException("LTI is not configured for this course", "LTI", "ltiNotConfigured"); } - ltiDeepLinkingService.initializeDeepLinkingResponse(ltiIdToken, clientRegistrationId); - ltiService.authenticateLtiUser(ltiIdToken.getEmail(), createUsernameFromLaunchRequest(ltiIdToken, onlineCourseConfiguration), ltiIdToken.getGivenName(), ltiIdToken.getFamilyName(), onlineCourseConfiguration.isRequireExistingUser()); } diff --git a/src/main/java/de/tum/in/www1/artemis/service/connectors/lti/LtiDeepLinkingService.java b/src/main/java/de/tum/in/www1/artemis/service/connectors/lti/LtiDeepLinkingService.java index 9ad6fd3a6148..dc5c8da745b0 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/connectors/lti/LtiDeepLinkingService.java +++ b/src/main/java/de/tum/in/www1/artemis/service/connectors/lti/LtiDeepLinkingService.java @@ -36,10 +36,6 @@ public class LtiDeepLinkingService { private final Lti13TokenRetriever tokenRetriever; - private Lti13DeepLinkingResponse lti13DeepLinkingResponse; - - private String clientRegistrationId; - /** * Constructor for LtiDeepLinkingService. * @@ -51,70 +47,60 @@ public LtiDeepLinkingService(ExerciseRepository exerciseRepository, Lti13TokenRe this.tokenRetriever = tokenRetriever; } + /** + * Constructs an LTI Deep Linking response URL with JWT for the specified course and exercise. + * + * @param ltiIdToken OIDC ID token with the user's authentication claims. + * @param clientRegistrationId Client registration ID for the LTI tool. + * @param courseId ID of the course for deep linking. + * @param exerciseId ID of the exercise for deep linking. + * @return Constructed deep linking response URL. + * @throws BadRequestAlertException if there are issues with the OIDC ID token claims. + */ + public String performDeepLinking(OidcIdToken ltiIdToken, String clientRegistrationId, Long courseId, Long exerciseId) { + // Initialize DeepLinkingResponse + Lti13DeepLinkingResponse lti13DeepLinkingResponse = new Lti13DeepLinkingResponse(ltiIdToken, clientRegistrationId); + // Fill selected exercise link into content items + String contentItems = this.populateContentItems(String.valueOf(courseId), String.valueOf(exerciseId)); + lti13DeepLinkingResponse.setContentItems(contentItems.toString()); + + // Prepare return url with jwt and id parameters + return this.buildLtiDeepLinkResponse(clientRegistrationId, lti13DeepLinkingResponse); + } + /** * Build an LTI deep linking response URL. * * @return The LTI deep link response URL. */ - public String buildLtiDeepLinkResponse() { + private String buildLtiDeepLinkResponse(String clientRegistrationId, Lti13DeepLinkingResponse lti13DeepLinkingResponse) { UriComponentsBuilder uriComponentsBuilder = UriComponentsBuilder.fromUriString(this.artemisServerUrl + "/lti/select-content"); - String jwt = tokenRetriever.createDeepLinkingJWT(this.clientRegistrationId, this.lti13DeepLinkingResponse.getClaims()); - String returnUrl = this.lti13DeepLinkingResponse.getReturnUrl(); + String jwt = tokenRetriever.createDeepLinkingJWT(clientRegistrationId, lti13DeepLinkingResponse.getClaims()); + String returnUrl = lti13DeepLinkingResponse.getReturnUrl(); // Validate properties are set to create a response - validateDeepLinkingResponseSettings(returnUrl, jwt); + validateDeepLinkingResponseSettings(returnUrl, jwt, lti13DeepLinkingResponse.getDeploymentId()); uriComponentsBuilder.queryParam("jwt", jwt); - uriComponentsBuilder.queryParam("id", this.lti13DeepLinkingResponse.getDeploymentId()); + uriComponentsBuilder.queryParam("id", lti13DeepLinkingResponse.getDeploymentId()); uriComponentsBuilder.queryParam("deepLinkUri", UriComponent.encode(returnUrl, UriComponent.Type.QUERY_PARAM)); return uriComponentsBuilder.build().toUriString(); } - /** - * Initialize the deep linking response with information from OidcIdToken and clientRegistrationId. - * - * @param ltiIdToken The LTI 1.3 ID token. - * @param clientRegistrationId The LTI 1.3 client registration id. - */ - public void initializeDeepLinkingResponse(OidcIdToken ltiIdToken, String clientRegistrationId) { - this.clientRegistrationId = clientRegistrationId; - this.lti13DeepLinkingResponse = new Lti13DeepLinkingResponse(ltiIdToken, clientRegistrationId); - } - /** * Populate content items for deep linking response. * * @param courseId The course ID. * @param exerciseId The exercise ID. */ - public void populateContentItems(String courseId, String exerciseId) { - if (this.lti13DeepLinkingResponse == null) { - throw new BadRequestAlertException("Deep linking response is not initialized correctly.", "LTI", "deepLinkingResponseInitializeFailed"); - } - + private String populateContentItems(String courseId, String exerciseId) { JsonObject item = setContentItem(courseId, exerciseId); JsonArray contentItems = new JsonArray(); contentItems.add(item); - this.lti13DeepLinkingResponse.setContentItems(contentItems.toString()); - } - - public String getClientRegistrationId() { - return clientRegistrationId; - } - - public void setClientRegistrationId(String clientRegistrationId) { - this.clientRegistrationId = clientRegistrationId; - } - - public Lti13DeepLinkingResponse getLti13DeepLinkingResponse() { - return lti13DeepLinkingResponse; - } - - public void setLti13DeepLinkingResponse(Lti13DeepLinkingResponse lti13DeepLinkingResponse) { - this.lti13DeepLinkingResponse = lti13DeepLinkingResponse; + return contentItems.toString(); } private JsonObject setContentItem(String courseId, String exerciseId) { @@ -131,7 +117,7 @@ private JsonObject createContentItem(String type, String title, String url) { return item; } - private void validateDeepLinkingResponseSettings(String returnURL, String jwt) { + private void validateDeepLinkingResponseSettings(String returnURL, String jwt, String deploymentId) { if (isEmptyString(jwt)) { throw new BadRequestAlertException("Deep linking response cannot be created", "LTI", "deepLinkingResponseFailed"); } @@ -140,9 +126,8 @@ private void validateDeepLinkingResponseSettings(String returnURL, String jwt) { throw new BadRequestAlertException("Cannot find platform return URL", "LTI", "deepLinkReturnURLEmpty"); } - if (isEmptyString(this.lti13DeepLinkingResponse.getDeploymentId())) { + if (isEmptyString(deploymentId)) throw new BadRequestAlertException("Platform deployment id cannot be empty", "LTI", "deploymentIdEmpty"); - } } boolean isEmptyString(String string) { diff --git a/src/main/java/de/tum/in/www1/artemis/web/rest/LtiResource.java b/src/main/java/de/tum/in/www1/artemis/web/rest/LtiResource.java index f69c2d376533..f0c0b6408a8f 100644 --- a/src/main/java/de/tum/in/www1/artemis/web/rest/LtiResource.java +++ b/src/main/java/de/tum/in/www1/artemis/web/rest/LtiResource.java @@ -1,10 +1,14 @@ package de.tum.in.www1.artemis.web.rest; +import java.text.ParseException; + import org.springframework.context.annotation.Profile; import org.springframework.http.ResponseEntity; +import org.springframework.security.oauth2.core.oidc.OidcIdToken; import org.springframework.web.bind.annotation.*; import com.google.gson.JsonObject; +import com.nimbusds.jwt.SignedJWT; import de.tum.in.www1.artemis.domain.Course; import de.tum.in.www1.artemis.repository.CourseRepository; @@ -61,9 +65,11 @@ public void lti13DynamicRegistration(@PathVariable Long courseId, @RequestParam( */ @PostMapping("/lti13/deep-linking/{courseId}") @EnforceAtLeastInstructor - public ResponseEntity lti13DeepLinking(@PathVariable Long courseId, @RequestParam(name = "exerciseId") String exerciseId) { - ltiDeepLinkingService.populateContentItems(String.valueOf(courseId), exerciseId); - String targetLink = ltiDeepLinkingService.buildLtiDeepLinkResponse(); + public ResponseEntity lti13DeepLinking(@PathVariable Long courseId, @RequestParam(name = "exerciseId") String exerciseId, + @RequestParam(name = "ltiIdToken") String ltiIdToken, @RequestParam(name = "clientRegistrationId") String clientRegistrationId) throws ParseException { + OidcIdToken idToken = new OidcIdToken(ltiIdToken, null, null, SignedJWT.parse(ltiIdToken).getJWTClaimsSet().getClaims()); + + String targetLink = ltiDeepLinkingService.performDeepLinking(idToken, clientRegistrationId, courseId, Long.valueOf(exerciseId)); JsonObject json = new JsonObject(); json.addProperty("targetLinkUri", targetLink); diff --git a/src/main/webapp/app/lti/lti13-deep-linking.component.ts b/src/main/webapp/app/lti/lti13-deep-linking.component.ts index 07ced4ac72f6..f02b2c510885 100644 --- a/src/main/webapp/app/lti/lti13-deep-linking.component.ts +++ b/src/main/webapp/app/lti/lti13-deep-linking.component.ts @@ -116,7 +116,11 @@ export class Lti13DeepLinkingComponent implements OnInit { */ sendDeepLinkRequest() { if (this.selectedExercise) { - const httpParams = new HttpParams().set('exerciseId', this.selectedExercise.id!); + const ltiIdToken = window.sessionStorage.getItem('ltiIdToken'); + const clientRegistrationId = window.sessionStorage.getItem('clientRegistrationId'); + + const httpParams = new HttpParams().set('exerciseId', this.selectedExercise.id!).set('ltiIdToken', ltiIdToken!).set('clientRegistrationId', clientRegistrationId!); + this.http.post(`api/lti13/deep-linking/${this.courseId}`, null, { observe: 'response', params: httpParams }).subscribe({ next: (response) => { if (response.status === 200) { diff --git a/src/main/webapp/app/lti/lti13-exercise-launch.component.ts b/src/main/webapp/app/lti/lti13-exercise-launch.component.ts index 028206a14f82..4e734aa45785 100644 --- a/src/main/webapp/app/lti/lti13-exercise-launch.component.ts +++ b/src/main/webapp/app/lti/lti13-exercise-launch.component.ts @@ -77,6 +77,11 @@ export class Lti13ExerciseLaunchComponent implements OnInit { } redirectUserToTargetLink(error: any): void { + const ltiIdToken = error.headers.get('ltiIdToken'); + const clientRegistrationId = error.headers.get('clientRegistrationId'); + + this.storeLtiSessionData(ltiIdToken, clientRegistrationId); + // Redirect to target link since the user is already logged in window.location.replace(error.headers.get('TargetLinkUri').toString()); } @@ -95,7 +100,11 @@ export class Lti13ExerciseLaunchComponent implements OnInit { handleLtiLaunchSuccess(data: NonNullable): void { const targetLinkUri = data['targetLinkUri']; + const ltiIdToken = data['ltiIdToken']; + const clientRegistrationId = data['clientRegistrationId']; + window.sessionStorage.removeItem('state'); + this.storeLtiSessionData(ltiIdToken, clientRegistrationId); if (targetLinkUri) { window.location.replace(targetLinkUri); @@ -109,4 +118,23 @@ export class Lti13ExerciseLaunchComponent implements OnInit { window.sessionStorage.removeItem('state'); this.isLaunching = false; } + + storeLtiSessionData(ltiIdToken: string, clientRegistrationId: string): void { + if (!ltiIdToken) { + console.error('LTI ID token required to store session data.'); + return; + } + + if (!clientRegistrationId) { + console.error('LTI client registration ID required to store session data.'); + return; + } + + try { + window.sessionStorage.setItem('ltiIdToken', ltiIdToken); + window.sessionStorage.setItem('clientRegistrationId', clientRegistrationId); + } catch (error) { + console.error('Failed to store session data:', error); + } + } } diff --git a/src/test/java/de/tum/in/www1/artemis/connectors/Lti13ServiceTest.java b/src/test/java/de/tum/in/www1/artemis/connectors/Lti13ServiceTest.java index ffd885d7f371..a418871006e5 100644 --- a/src/test/java/de/tum/in/www1/artemis/connectors/Lti13ServiceTest.java +++ b/src/test/java/de/tum/in/www1/artemis/connectors/Lti13ServiceTest.java @@ -506,8 +506,6 @@ void startDeepLinkingCourseFound() { doNothing().when(ltiService).onSuccessfulLtiAuthentication(any(), any()); lti13Service.startDeepLinking(oidcIdToken, clientRegistrationId); - - verify(ltiDeepLinkingService).initializeDeepLinkingResponse(oidcIdToken, clientRegistrationId); } @Test @@ -533,19 +531,13 @@ void startDeepLinkingNotOnlineCourse() { @Test void startDeepLinkingCourseNotFound() { when(oidcIdToken.getClaim(Claims.TARGET_LINK_URI)).thenReturn("https://some-artemis-domain.org/lti/deep-linking/100000"); - assertThatExceptionOfType(BadRequestAlertException.class).isThrownBy(() -> lti13Service.startDeepLinking(oidcIdToken, clientRegistrationId)); - - verify(ltiDeepLinkingService, never()).initializeDeepLinkingResponse(any(), any()); } @Test void startDeepLinkingInvalidPath() { when(oidcIdToken.getClaim(Claims.TARGET_LINK_URI)).thenReturn("https://some-artemis-domain.org/with/invalid/path/to/deeplinking/11"); - assertThatExceptionOfType(BadRequestAlertException.class).isThrownBy(() -> lti13Service.startDeepLinking(oidcIdToken, clientRegistrationId)); - - verify(ltiDeepLinkingService, never()).initializeDeepLinkingResponse(any(), any()); } @Test @@ -553,8 +545,6 @@ void startDeepLinkingMalformedUrl() { doReturn("path").when(oidcIdToken).getClaim(Claims.TARGET_LINK_URI); assertThatExceptionOfType(BadRequestAlertException.class).isThrownBy(() -> lti13Service.startDeepLinking(oidcIdToken, clientRegistrationId)); - - verify(ltiDeepLinkingService, never()).initializeDeepLinkingResponse(any(), any()); } private State getValidStateForNewResult(Result result) { diff --git a/src/test/java/de/tum/in/www1/artemis/connectors/LtiDeepLinkingServiceTest.java b/src/test/java/de/tum/in/www1/artemis/connectors/LtiDeepLinkingServiceTest.java index 468e3dd2ec40..c1007728ce00 100644 --- a/src/test/java/de/tum/in/www1/artemis/connectors/LtiDeepLinkingServiceTest.java +++ b/src/test/java/de/tum/in/www1/artemis/connectors/LtiDeepLinkingServiceTest.java @@ -55,7 +55,7 @@ void tearDown() throws Exception { } @Test - void testBuildLtiDeepLinkResponse() { + void testPerformDeepLinking() { createMockOidcIdToken(); when(tokenRetriever.createDeepLinkingJWT(anyString(), anyMap())).thenReturn("test_jwt"); @@ -64,10 +64,7 @@ void testBuildLtiDeepLinkResponse() { Exercise exercise = createMockExercise(exerciseId, courseId); when(exerciseRepository.findById(exerciseId)).thenReturn(Optional.of(exercise)); - ltiDeepLinkingService.initializeDeepLinkingResponse(oidcIdToken, "test_registration_id"); - ltiDeepLinkingService.populateContentItems(String.valueOf(courseId), String.valueOf(exerciseId)); - - String deepLinkResponse = ltiDeepLinkingService.buildLtiDeepLinkResponse(); + String deepLinkResponse = ltiDeepLinkingService.performDeepLinking(oidcIdToken, "test_registration_id", courseId, exerciseId); assertThat(deepLinkResponse).isNotNull(); assertThat(deepLinkResponse).contains("test_jwt"); @@ -83,10 +80,8 @@ void testEmptyJwtBuildLtiDeepLinkResponse() { Exercise exercise = createMockExercise(exerciseId, courseId); when(exerciseRepository.findById(exerciseId)).thenReturn(Optional.of(exercise)); - ltiDeepLinkingService.initializeDeepLinkingResponse(oidcIdToken, "test_registration_id"); - ltiDeepLinkingService.populateContentItems(String.valueOf(courseId), String.valueOf(exerciseId)); - - assertThatExceptionOfType(BadRequestAlertException.class).isThrownBy(() -> ltiDeepLinkingService.buildLtiDeepLinkResponse()) + assertThatExceptionOfType(BadRequestAlertException.class) + .isThrownBy(() -> ltiDeepLinkingService.performDeepLinking(oidcIdToken, "test_registration_id", courseId, exerciseId)) .withMessage("Deep linking response cannot be created") .matches(exception -> "LTI".equals(exception.getEntityName()) && "deepLinkingResponseFailed".equals(exception.getErrorKey())); } @@ -95,17 +90,20 @@ void testEmptyJwtBuildLtiDeepLinkResponse() { void testEmptyReturnUrlBuildLtiDeepLinkResponse() { createMockOidcIdToken(); when(tokenRetriever.createDeepLinkingJWT(anyString(), anyMap())).thenReturn("test_jwt"); + String jsonString = "{ \"deep_link_return_url\": \"\", " + "\"accept_types\": [\"link\", \"file\", \"html\", \"ltiResourceLink\", \"image\"], " + + "\"accept_media_types\": \"image/*,text/html\", " + "\"accept_presentation_document_targets\": [\"iframe\", \"window\", \"embed\"], " + + "\"accept_multiple\": true, " + "\"auto_create\": true, " + "\"title\": \"This is the default title\", " + "\"text\": \"This is the default text\", " + + "\"data\": \"csrftoken:c7fbba78-7b75-46e3-9201-11e6d5f36f53\"" + "}"; + when(oidcIdToken.getClaim(de.tum.in.www1.artemis.domain.lti.Claims.DEEP_LINKING_SETTINGS)).thenReturn(jsonString); long exerciseId = 3; long courseId = 17; Exercise exercise = createMockExercise(exerciseId, courseId); when(exerciseRepository.findById(exerciseId)).thenReturn(Optional.of(exercise)); - ltiDeepLinkingService.initializeDeepLinkingResponse(oidcIdToken, "test_registration_id"); - ltiDeepLinkingService.populateContentItems(String.valueOf(courseId), String.valueOf(exerciseId)); - ltiDeepLinkingService.getLti13DeepLinkingResponse().setReturnUrl(null); - - assertThatExceptionOfType(BadRequestAlertException.class).isThrownBy(() -> ltiDeepLinkingService.buildLtiDeepLinkResponse()).withMessage("Cannot find platform return URL") + assertThatExceptionOfType(BadRequestAlertException.class) + .isThrownBy(() -> ltiDeepLinkingService.performDeepLinking(oidcIdToken, "test_registration_id", courseId, exerciseId)) + .withMessage("Cannot find platform return URL") .matches(exception -> "LTI".equals(exception.getEntityName()) && "deepLinkReturnURLEmpty".equals(exception.getErrorKey())); } @@ -113,66 +111,16 @@ void testEmptyReturnUrlBuildLtiDeepLinkResponse() { void testEmptyDeploymentIdBuildLtiDeepLinkResponse() { createMockOidcIdToken(); when(tokenRetriever.createDeepLinkingJWT(anyString(), anyMap())).thenReturn("test_jwt"); + when(oidcIdToken.getClaim(de.tum.in.www1.artemis.domain.lti.Claims.LTI_DEPLOYMENT_ID)).thenReturn(null); long exerciseId = 3; long courseId = 17; Exercise exercise = createMockExercise(exerciseId, courseId); when(exerciseRepository.findById(exerciseId)).thenReturn(Optional.of(exercise)); - ltiDeepLinkingService.initializeDeepLinkingResponse(oidcIdToken, "test_registration_id"); - ltiDeepLinkingService.populateContentItems(String.valueOf(courseId), String.valueOf(exerciseId)); - ltiDeepLinkingService.getLti13DeepLinkingResponse().setDeploymentId(null); - - assertThatExceptionOfType(BadRequestAlertException.class).isThrownBy(() -> ltiDeepLinkingService.buildLtiDeepLinkResponse()) - .withMessage("Platform deployment id cannot be empty") - .matches(exception -> "LTI".equals(exception.getEntityName()) && "deploymentIdEmpty".equals(exception.getErrorKey())); - } - - @Test - void testInitializeDeepLinkingResponse() { - createMockOidcIdToken(); - - ltiDeepLinkingService.initializeDeepLinkingResponse(oidcIdToken, "test_registration_id"); - - assertThat(ltiDeepLinkingService).isNotNull(); - assertThat("test_registration_id").isEqualTo(ltiDeepLinkingService.getClientRegistrationId()); - assertThat(ltiDeepLinkingService.getLti13DeepLinkingResponse()).isNotNull(); - } - - @Test - void testBadRequestForUninitializedDeepLinkingResponseInPopulateContentItems() { - long exerciseId = 3; - long courseId = 14; - Exercise exercise = createMockExercise(exerciseId, courseId); - - when(exerciseRepository.findById(exerciseId)).thenReturn(Optional.of(exercise)); - - assertThatExceptionOfType(BadRequestAlertException.class) - .isThrownBy(() -> ltiDeepLinkingService.populateContentItems(String.valueOf(courseId), String.valueOf(exerciseId))); - - } - - @Test - void testInitializeAndAddContentDeepLinkingResponse() { - createMockOidcIdToken(); - - long exerciseId = 3; - long courseId = 17; - Exercise exercise = createMockExercise(exerciseId, courseId); - - String targetUrl = "courses/" + courseId + "/exercise/" + exerciseId; - when(exerciseRepository.findById(exerciseId)).thenReturn(Optional.of(exercise)); - - ltiDeepLinkingService.initializeDeepLinkingResponse(oidcIdToken, "test_registration_id"); - ltiDeepLinkingService.populateContentItems(String.valueOf(courseId), String.valueOf(exerciseId)); - - assertThat(ltiDeepLinkingService).isNotNull(); - assertThat("test_registration_id").isEqualTo(ltiDeepLinkingService.getClientRegistrationId()); - assertThat(ltiDeepLinkingService.getLti13DeepLinkingResponse()).isNotNull(); - assertThat(ltiDeepLinkingService.getLti13DeepLinkingResponse()).isNotNull(); - assertThat(ltiDeepLinkingService.getLti13DeepLinkingResponse().getContentItems().contains("test_title")); - assertThat(ltiDeepLinkingService.getLti13DeepLinkingResponse().getContentItems().contains(exercise.getType())); - assertThat(ltiDeepLinkingService.getLti13DeepLinkingResponse().getContentItems().contains(targetUrl)); + assertThatExceptionOfType(IllegalArgumentException.class) + .isThrownBy(() -> ltiDeepLinkingService.performDeepLinking(oidcIdToken, "test_registration_id", courseId, exerciseId)) + .withMessage("Missing claim: " + Claims.LTI_DEPLOYMENT_ID); } private void createMockOidcIdToken() { From 43b0176f5df63103bff54c36c43f1f80ac4ad896 Mon Sep 17 00:00:00 2001 From: Basak Akan Date: Thu, 16 Nov 2023 18:56:05 +0100 Subject: [PATCH 49/88] implement code review --- .../domain/lti/Lti13DeepLinkingResponse.java | 3 -- .../security/lti/Lti13LaunchFilter.java | 2 +- .../service/connectors/lti/Lti13Service.java | 3 +- .../connectors/lti/LtiDeepLinkingService.java | 4 --- .../app/lti/lti13-deep-linking.component.html | 6 ++-- .../app/lti/lti13-deep-linking.component.ts | 36 ++++++++++--------- .../lti/lti13-select-content.component.html | 6 ++-- .../app/lti/lti13-select-content.component.ts | 19 ++++++---- src/main/webapp/i18n/de/lti.json | 6 ++-- 9 files changed, 44 insertions(+), 41 deletions(-) diff --git a/src/main/java/de/tum/in/www1/artemis/domain/lti/Lti13DeepLinkingResponse.java b/src/main/java/de/tum/in/www1/artemis/domain/lti/Lti13DeepLinkingResponse.java index 2cef96a14012..5b79999a21e2 100644 --- a/src/main/java/de/tum/in/www1/artemis/domain/lti/Lti13DeepLinkingResponse.java +++ b/src/main/java/de/tum/in/www1/artemis/domain/lti/Lti13DeepLinkingResponse.java @@ -52,9 +52,6 @@ public class Lti13DeepLinkingResponse { private String returnUrl; - public Lti13DeepLinkingResponse() { - } - /** * Constructs an Lti13DeepLinkingResponse from an OIDC ID token and client registration ID. * diff --git a/src/main/java/de/tum/in/www1/artemis/security/lti/Lti13LaunchFilter.java b/src/main/java/de/tum/in/www1/artemis/security/lti/Lti13LaunchFilter.java index fce874d9f800..d5a51b9b9381 100644 --- a/src/main/java/de/tum/in/www1/artemis/security/lti/Lti13LaunchFilter.java +++ b/src/main/java/de/tum/in/www1/artemis/security/lti/Lti13LaunchFilter.java @@ -63,7 +63,7 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse try { // here we need to check if this is a deep-linking request or a launch request - if (ltiIdToken.getClaim(Claims.MESSAGE_TYPE) != null && ltiIdToken.getClaim(Claims.MESSAGE_TYPE).equals("LtiDeepLinkingRequest")) { + if ("LtiDeepLinkingRequest".equals(ltiIdToken.getClaim(Claims.MESSAGE_TYPE))) { lti13Service.startDeepLinking(ltiIdToken, authToken.getAuthorizedClientRegistrationId()); } else { diff --git a/src/main/java/de/tum/in/www1/artemis/service/connectors/lti/Lti13Service.java b/src/main/java/de/tum/in/www1/artemis/service/connectors/lti/Lti13Service.java index ac465ed01597..c0dc9568c0dc 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/connectors/lti/Lti13Service.java +++ b/src/main/java/de/tum/in/www1/artemis/service/connectors/lti/Lti13Service.java @@ -350,8 +350,7 @@ public void startDeepLinking(OidcIdToken ltiIdToken, String clientRegistrationId String targetLinkUrl = ltiIdToken.getClaim(Claims.TARGET_LINK_URI); Course targetCourse = getCourseFromTargetLink(targetLinkUrl); if (targetCourse == null) { - String message = "No course to start deep-linking at " + targetLinkUrl; - log.error(message); + log.error("No course to start deep-linking at {}", targetLinkUrl); throw new BadRequestAlertException("Course not found", "LTI", "ltiCourseNotFound"); } diff --git a/src/main/java/de/tum/in/www1/artemis/service/connectors/lti/LtiDeepLinkingService.java b/src/main/java/de/tum/in/www1/artemis/service/connectors/lti/LtiDeepLinkingService.java index dc5c8da745b0..6159d0d31092 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/connectors/lti/LtiDeepLinkingService.java +++ b/src/main/java/de/tum/in/www1/artemis/service/connectors/lti/LtiDeepLinkingService.java @@ -3,8 +3,6 @@ import java.util.Optional; import org.glassfish.jersey.uri.UriComponent; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Profile; import org.springframework.security.oauth2.core.oidc.OidcIdToken; @@ -30,8 +28,6 @@ public class LtiDeepLinkingService { @Value("${server.url}") private String artemisServerUrl; - private final Logger log = LoggerFactory.getLogger(LtiDeepLinkingService.class); - private final ExerciseRepository exerciseRepository; private final Lti13TokenRetriever tokenRetriever; diff --git a/src/main/webapp/app/lti/lti13-deep-linking.component.html b/src/main/webapp/app/lti/lti13-deep-linking.component.html index 16bb3d2cc223..a8c67eaae2f9 100644 --- a/src/main/webapp/app/lti/lti13-deep-linking.component.html +++ b/src/main/webapp/app/lti/lti13-deep-linking.component.html @@ -59,9 +59,9 @@

Linking...

-
- - + + +

Error during deep linking

diff --git a/src/main/webapp/app/lti/lti13-select-content.component.ts b/src/main/webapp/app/lti/lti13-select-content.component.ts index ced28fe1d05a..c76a9aa1bbd5 100644 --- a/src/main/webapp/app/lti/lti13-select-content.component.ts +++ b/src/main/webapp/app/lti/lti13-select-content.component.ts @@ -1,4 +1,4 @@ -import { Component, OnInit, SecurityContext } from '@angular/core'; +import { Component, ElementRef, NgZone, OnInit, SecurityContext, ViewChild } from '@angular/core'; import { ActivatedRoute } from '@angular/router'; import { DomSanitizer } from '@angular/platform-browser'; @@ -18,9 +18,13 @@ export class Lti13SelectContentComponent implements OnInit { actionLink: string; isLinking = true; + @ViewChild('deepLinkingForm', { static: false }) + deepLinkingForm?: ElementRef; + constructor( private route: ActivatedRoute, private sanitizer: DomSanitizer, + private zone: NgZone, ) {} /** @@ -32,8 +36,13 @@ export class Lti13SelectContentComponent implements OnInit { ngOnInit(): void { this.route.params.subscribe(() => { this.updateFormValues(); - if (this.isLinking) { - this.autoSubmitForm(); + + // postpone auto-submit until after view updates are completed + // if not jwt and id is not submitted correctly + if (this.jwt && this.id) { + this.zone.runOutsideAngular(() => { + setTimeout(() => this.autoSubmitForm()); + }); } }); } @@ -59,10 +68,8 @@ export class Lti13SelectContentComponent implements OnInit { * - Submits the form. */ autoSubmitForm(): void { - const form = document.getElementById('deepLinkingForm') as HTMLFormElement; + const form = this.deepLinkingForm!.nativeElement; form.action = this.actionLink; - (document.getElementById('JWT'))!.value = this.jwt; - (document.getElementById('id'))!.value = this.id; form.submit(); } } diff --git a/src/main/webapp/i18n/de/lti.json b/src/main/webapp/i18n/de/lti.json index 07d84747d28f..789d16e0c678 100644 --- a/src/main/webapp/i18n/de/lti.json +++ b/src/main/webapp/i18n/de/lti.json @@ -78,13 +78,13 @@ "deepLinking": { "title": "Deep Linking", "linking": "Verlinkung", - "linkedSuccessfully": "Verknüpfte Übungen erfolgreich", + "linkedSuccessfully": "Verknüpfung der Übungen erfolgreich", "linkedFailed": "Fehler beim Deep-Linking", "link": "Verlinken" }, "missingConfigurationWarning": "Fehlende Werte in der LTI1.3-Konfiguration. Starts werden nicht funktionieren.", - "selectContentFromCourse": "Wählen Sie Inhalte aus dem Kurs aus {{ title }}", - "selectContentTooltip": "Wählen Sie einfach Ihre bevorzugte Übung aus und klicken Sie dann auf die Schaltfläche Importieren, um sie in die Plattform zu integrieren." + "selectContentFromCourse": "Wähle Inhalte aus dem Kurs aus {{ title }}", + "selectContentTooltip": "Wähle eine Übung aus und klicke dann auf die Schaltfläche Importieren, um sie in die Plattform zu integrieren." } } } From 6074bcc0e52343701b32b7259833993696292194 Mon Sep 17 00:00:00 2001 From: Basak Akan Date: Thu, 16 Nov 2023 19:48:23 +0100 Subject: [PATCH 50/88] fix server tests --- .../security/lti/Lti13LaunchFilter.java | 9 ++- .../in/www1/artemis/LtiIntegrationTest.java | 14 +++-- .../artemis/connectors/Lti13ServiceTest.java | 33 ++++++---- .../security/Lti13LaunchFilterTest.java | 61 +++++++++---------- 4 files changed, 66 insertions(+), 51 deletions(-) diff --git a/src/main/java/de/tum/in/www1/artemis/security/lti/Lti13LaunchFilter.java b/src/main/java/de/tum/in/www1/artemis/security/lti/Lti13LaunchFilter.java index d5a51b9b9381..bc9c633d61f9 100644 --- a/src/main/java/de/tum/in/www1/artemis/security/lti/Lti13LaunchFilter.java +++ b/src/main/java/de/tum/in/www1/artemis/security/lti/Lti13LaunchFilter.java @@ -56,11 +56,14 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse return; } - OidcAuthenticationToken authToken = finishOidcFlow(request, response); - OidcIdToken ltiIdToken = ((OidcUser) authToken.getPrincipal()).getIdToken(); - String targetLink = ltiIdToken.getClaim(Claims.TARGET_LINK_URI).toString(); + OidcAuthenticationToken authToken = null; + OidcIdToken ltiIdToken = null; + String targetLink = ""; try { + authToken = finishOidcFlow(request, response); + ltiIdToken = ((OidcUser) authToken.getPrincipal()).getIdToken(); + targetLink = ltiIdToken.getClaim(Claims.TARGET_LINK_URI).toString(); // here we need to check if this is a deep-linking request or a launch request if ("LtiDeepLinkingRequest".equals(ltiIdToken.getClaim(Claims.MESSAGE_TYPE))) { diff --git a/src/test/java/de/tum/in/www1/artemis/LtiIntegrationTest.java b/src/test/java/de/tum/in/www1/artemis/LtiIntegrationTest.java index b7f2a8b7c283..fead73dba7a3 100644 --- a/src/test/java/de/tum/in/www1/artemis/LtiIntegrationTest.java +++ b/src/test/java/de/tum/in/www1/artemis/LtiIntegrationTest.java @@ -348,8 +348,7 @@ void dynamicRegistrationFailsForNonOnlineCourse() throws Exception { @Test @WithMockUser(username = TEST_PREFIX + "student1", roles = "USER") void deepLinkingFailsAsStudent() throws Exception { - var params = new LinkedMultiValueMap(); - params.add("exerciseId", "155"); + var params = getDeepLinkingRequestParams(); request.postWithoutResponseBody("/api/lti13/deep-linking/" + course.getId(), HttpStatus.FORBIDDEN, params); } @@ -367,8 +366,7 @@ void deepLinkingFailsForNonOnlineCourse() throws Exception { course.setOnlineCourseConfiguration(null); courseRepository.save(course); - var params = new LinkedMultiValueMap(); - params.add("exerciseId", "155"); + var params = getDeepLinkingRequestParams(); request.postWithoutResponseBody("/api/lti13/dynamic-registration/" + course.getId(), HttpStatus.BAD_REQUEST, params); } @@ -382,4 +380,12 @@ private void assertParametersNewStudent(MultiValueMap parameters assertThat(parameters.getFirst("initialize")).isNotNull(); assertThat(parameters.getFirst("ltiSuccessLoginRequired")).isNull(); } + + private static LinkedMultiValueMap getDeepLinkingRequestParams() { + var params = new LinkedMultiValueMap(); + params.add("exerciseId", "155"); + params.add("ltiIdToken", "id-token"); + params.add("clientRegistrationId", "registration-id"); + return params; + } } diff --git a/src/test/java/de/tum/in/www1/artemis/connectors/Lti13ServiceTest.java b/src/test/java/de/tum/in/www1/artemis/connectors/Lti13ServiceTest.java index a418871006e5..4e5258616a78 100644 --- a/src/test/java/de/tum/in/www1/artemis/connectors/Lti13ServiceTest.java +++ b/src/test/java/de/tum/in/www1/artemis/connectors/Lti13ServiceTest.java @@ -109,17 +109,7 @@ void tearDown() throws Exception { @Test void performLaunch_exerciseFound() { - long exerciseId = 134; - Exercise exercise = new TextExercise(); - exercise.setId(exerciseId); - - long courseId = 12; - Course course = new Course(); - course.setId(courseId); - course.setOnlineCourseConfiguration(new OnlineCourseConfiguration()); - exercise.setCourse(course); - doReturn(Optional.of(exercise)).when(exerciseRepository).findById(exerciseId); - doReturn(course).when(courseRepository).findByIdWithEagerOnlineCourseConfigurationElseThrow(courseId); + mockExercise result = getMockExercise(); when(oidcIdToken.getClaim("sub")).thenReturn("1"); when(oidcIdToken.getClaim("iss")).thenReturn("http://otherDomain.com"); @@ -127,7 +117,7 @@ void performLaunch_exerciseFound() { JsonObject jsonObject = new JsonObject(); jsonObject.addProperty("id", "resourceLinkUrl"); when(oidcIdToken.getClaim(Claims.RESOURCE_LINK)).thenReturn(jsonObject); - prepareForPerformLaunch(courseId, exerciseId); + prepareForPerformLaunch(result.courseId(), result.exerciseId()); lti13Service.performLaunch(oidcIdToken, clientRegistrationId); @@ -583,6 +573,25 @@ private record State(LtiResourceLaunch ltiResourceLaunch, Exercise exercise, Use ClientRegistration clientRegistration) { } + private mockExercise getMockExercise() { + long exerciseId = 134; + Exercise exercise = new TextExercise(); + exercise.setId(exerciseId); + + long courseId = 12; + Course course = new Course(); + course.setId(courseId); + course.setOnlineCourseConfiguration(new OnlineCourseConfiguration()); + exercise.setCourse(course); + doReturn(Optional.of(exercise)).when(exerciseRepository).findById(exerciseId); + doReturn(course).when(courseRepository).findByIdWithEagerOnlineCourseConfigurationElseThrow(courseId); + mockExercise result = new mockExercise(exerciseId, courseId); + return result; + } + + private record mockExercise(long exerciseId, long courseId) { + } + private void prepareForPerformLaunch(long courseId, long exerciseId) { when(oidcIdToken.getEmail()).thenReturn("testuser@email.com"); when(oidcIdToken.getClaim(Claims.TARGET_LINK_URI)).thenReturn("https://some-artemis-domain.org/courses/" + courseId + "/exercises/" + exerciseId); diff --git a/src/test/java/de/tum/in/www1/artemis/security/Lti13LaunchFilterTest.java b/src/test/java/de/tum/in/www1/artemis/security/Lti13LaunchFilterTest.java index 87a12487fbd0..08c2c618be17 100644 --- a/src/test/java/de/tum/in/www1/artemis/security/Lti13LaunchFilterTest.java +++ b/src/test/java/de/tum/in/www1/artemis/security/Lti13LaunchFilterTest.java @@ -3,11 +3,13 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.Mockito.*; +import java.io.IOException; import java.io.PrintWriter; import java.util.HashMap; import java.util.Map; import javax.servlet.FilterChain; +import javax.servlet.ServletException; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; @@ -130,45 +132,15 @@ private void initValidTokenForDeepLinking() { @Test void authenticatedLogin() throws Exception { - doReturn(true).when(authentication).isAuthenticated(); - doReturn(CustomLti13Configurer.LTI13_LOGIN_PATH).when(httpRequest).getServletPath(); - doReturn(oidcToken).when(defaultFilter).attemptAuthentication(any(), any()); - doReturn(responseWriter).when(httpResponse).getWriter(); - initValidIdToken(); - - launchFilter.doFilter(httpRequest, httpResponse, filterChain); - - verify(httpResponse, never()).setStatus(HttpStatus.UNAUTHORIZED.value()); - verify(httpResponse).setContentType("application/json"); - verify(httpResponse).setCharacterEncoding("UTF-8"); + JsonObject responseJsonBody = getMockJsonObject(false); verify(lti13Service).performLaunch(any(), any()); - - ArgumentCaptor argument = ArgumentCaptor.forClass(JsonObject.class); - verify(responseWriter).print(argument.capture()); - JsonObject responseJsonBody = argument.getValue(); - verify(lti13Service).buildLtiResponse(any(), any()); assertThat((responseJsonBody.get("targetLinkUri").getAsString())).as("Response body contains the expected targetLinkUri").contains(this.targetLinkUri); } @Test void authenticatedLoginForDeepLinking() throws Exception { - doReturn(true).when(authentication).isAuthenticated(); - doReturn(CustomLti13Configurer.LTI13_LOGIN_PATH).when(httpRequest).getServletPath(); - doReturn(oidcToken).when(defaultFilter).attemptAuthentication(any(), any()); - doReturn(responseWriter).when(httpResponse).getWriter(); - initValidTokenForDeepLinking(); - - launchFilter.doFilter(httpRequest, httpResponse, filterChain); - - verify(httpResponse, never()).setStatus(HttpStatus.UNAUTHORIZED.value()); - verify(httpResponse).setContentType("application/json"); - verify(httpResponse).setCharacterEncoding("UTF-8"); + JsonObject responseJsonBody = getMockJsonObject(true); verify(lti13Service).startDeepLinking(any(), any()); - - ArgumentCaptor argument = ArgumentCaptor.forClass(JsonObject.class); - verify(responseWriter).print(argument.capture()); - JsonObject responseJsonBody = argument.getValue(); - verify(lti13Service).buildLtiResponse(any(), any()); assertThat((responseJsonBody.get("targetLinkUri").toString())).as("Response body contains the expected targetLinkUri") .contains("https://any-artemis-domain.org/lti/deep-linking/121"); @@ -210,4 +182,29 @@ void authenticatedLogin_serviceLaunchFailed() throws Exception { verify(httpResponse).sendError(eq(HttpStatus.INTERNAL_SERVER_ERROR.value()), any()); } + + private JsonObject getMockJsonObject(boolean isDeepLinkingRequest) throws IOException, ServletException { + doReturn(true).when(authentication).isAuthenticated(); + doReturn(CustomLti13Configurer.LTI13_LOGIN_PATH).when(httpRequest).getServletPath(); + doReturn(oidcToken).when(defaultFilter).attemptAuthentication(any(), any()); + doReturn(responseWriter).when(httpResponse).getWriter(); + if (isDeepLinkingRequest) { + initValidTokenForDeepLinking(); + } + else { + initValidIdToken(); + } + + launchFilter.doFilter(httpRequest, httpResponse, filterChain); + + verify(httpResponse, never()).setStatus(HttpStatus.UNAUTHORIZED.value()); + verify(httpResponse).setContentType("application/json"); + verify(httpResponse).setCharacterEncoding("UTF-8"); + + ArgumentCaptor argument = ArgumentCaptor.forClass(JsonObject.class); + verify(responseWriter).print(argument.capture()); + JsonObject responseJsonBody = argument.getValue(); + verify(lti13Service).buildLtiResponse(any(), any()); + return responseJsonBody; + } } From 7f42db47346da555f6ccfedb6958310d4688cd0a Mon Sep 17 00:00:00 2001 From: Basak Akan Date: Thu, 16 Nov 2023 20:42:12 +0100 Subject: [PATCH 51/88] fix client tests --- .../app/lti/lti13-deep-linking.component.ts | 4 ++-- .../app/lti/lti13-select-content.component.ts | 8 +++++--- .../lti/lti13-deep-linking.component.spec.ts | 13 ++++++++----- .../lti13-exercise-launch.component.spec.ts | 6 +++--- .../lti/lti13-select-content.component.spec.ts | 18 +++++++++--------- 5 files changed, 27 insertions(+), 22 deletions(-) diff --git a/src/main/webapp/app/lti/lti13-deep-linking.component.ts b/src/main/webapp/app/lti/lti13-deep-linking.component.ts index b119cfe48262..7c02ecdb70cf 100644 --- a/src/main/webapp/app/lti/lti13-deep-linking.component.ts +++ b/src/main/webapp/app/lti/lti13-deep-linking.component.ts @@ -120,8 +120,8 @@ export class Lti13DeepLinkingComponent implements OnInit { */ sendDeepLinkRequest() { if (this.selectedExercise) { - const ltiIdToken = window.sessionStorage.getItem('ltiIdToken'); - const clientRegistrationId = window.sessionStorage.getItem('clientRegistrationId'); + const ltiIdToken = window.sessionStorage.getItem('ltiIdToken') ?? ''; + const clientRegistrationId = window.sessionStorage.getItem('clientRegistrationId') ?? ''; const httpParams = new HttpParams().set('exerciseId', this.selectedExercise.id!).set('ltiIdToken', ltiIdToken!).set('clientRegistrationId', clientRegistrationId!); diff --git a/src/main/webapp/app/lti/lti13-select-content.component.ts b/src/main/webapp/app/lti/lti13-select-content.component.ts index c76a9aa1bbd5..f4afb5cca6b8 100644 --- a/src/main/webapp/app/lti/lti13-select-content.component.ts +++ b/src/main/webapp/app/lti/lti13-select-content.component.ts @@ -68,8 +68,10 @@ export class Lti13SelectContentComponent implements OnInit { * - Submits the form. */ autoSubmitForm(): void { - const form = this.deepLinkingForm!.nativeElement; - form.action = this.actionLink; - form.submit(); + const form = this.deepLinkingForm?.nativeElement; + if (form) { + form.action = this.actionLink; + form.submit(); + } } } diff --git a/src/test/javascript/spec/component/lti/lti13-deep-linking.component.spec.ts b/src/test/javascript/spec/component/lti/lti13-deep-linking.component.spec.ts index 84268b1aa352..2212f7beca4f 100644 --- a/src/test/javascript/spec/component/lti/lti13-deep-linking.component.spec.ts +++ b/src/test/javascript/spec/component/lti/lti13-deep-linking.component.spec.ts @@ -7,11 +7,13 @@ import { AccountService } from 'app/core/auth/account.service'; import { SortService } from 'app/shared/service/sort.service'; import { of, throwError } from 'rxjs'; import { ArtemisTranslatePipe } from 'app/shared/pipes/artemis-translate.pipe'; -import { MockPipe } from 'ng-mocks'; +import { MockPipe, MockProvider } from 'ng-mocks'; import { User } from 'app/core/user/user.model'; import { Course } from 'app/entities/course.model'; import { Exercise, ExerciseType } from 'app/entities/exercise.model'; import { HelpIconComponent } from 'app/shared/components/help-icon.component'; +import { ArtemisDatePipe } from 'app/shared/pipes/artemis-date.pipe'; +import { AlertService } from 'app/core/util/alert.service'; describe('Lti13DeepLinkingComponent', () => { let component: Lti13DeepLinkingComponent; @@ -33,7 +35,7 @@ describe('Lti13DeepLinkingComponent', () => { activatedRouteMock = { params: of({ courseId: '123' }) }; TestBed.configureTestingModule({ - declarations: [Lti13DeepLinkingComponent, MockPipe(ArtemisTranslatePipe), HelpIconComponent], + declarations: [Lti13DeepLinkingComponent, MockPipe(ArtemisTranslatePipe), HelpIconComponent, MockPipe(ArtemisDatePipe)], providers: [ { provide: ActivatedRoute, useValue: activatedRouteMock }, { provide: Router, useValue: routerMock }, @@ -41,6 +43,7 @@ describe('Lti13DeepLinkingComponent', () => { { provide: CourseManagementService, useValue: courseManagementServiceMock }, { provide: AccountService, useValue: accountServiceMock }, { provide: SortService, useValue: sortServiceMock }, + MockProvider(AlertService), ], }).compileComponents(); jest.spyOn(console, 'error').mockImplementation(() => {}); @@ -128,7 +131,7 @@ describe('Lti13DeepLinkingComponent', () => { expect(component.isLinking).toBeFalse(); expect(httpMock.post).toHaveBeenCalledWith(`api/lti13/deep-linking/${component.courseId}`, null, { observe: 'response', - params: new HttpParams().set('exerciseId', exercise1.id!), + params: new HttpParams().set('exerciseId', exercise1.id!).set('ltiIdToken', '').set('clientRegistrationId', ''), }); expect(replaceMock).not.toHaveBeenCalled(); // Verify that we did not navigate })); @@ -145,7 +148,7 @@ describe('Lti13DeepLinkingComponent', () => { expect(component.isLinking).toBeFalse(); expect(httpMock.post).toHaveBeenCalledWith(`api/lti13/deep-linking/${component.courseId}`, null, { observe: 'response', - params: new HttpParams().set('exerciseId', exercise1.id!), + params: new HttpParams().set('exerciseId', exercise1.id!).set('ltiIdToken', '').set('clientRegistrationId', ''), }); })); @@ -168,7 +171,7 @@ describe('Lti13DeepLinkingComponent', () => { expect(httpMock.post).toHaveBeenCalledWith(`api/lti13/deep-linking/${component.courseId}`, null, { observe: 'response', - params: new HttpParams().set('exerciseId', exercise1.id!), + params: new HttpParams().set('exerciseId', exercise1.id!).set('ltiIdToken', '').set('clientRegistrationId', ''), }); }); }); diff --git a/src/test/javascript/spec/component/lti/lti13-exercise-launch.component.spec.ts b/src/test/javascript/spec/component/lti/lti13-exercise-launch.component.spec.ts index d46307a46cd3..b99f85726c3d 100644 --- a/src/test/javascript/spec/component/lti/lti13-exercise-launch.component.spec.ts +++ b/src/test/javascript/spec/component/lti/lti13-exercise-launch.component.spec.ts @@ -97,14 +97,14 @@ describe('Lti13ExerciseLaunchComponent', () => { }); it('onInit no targetLinkUri', () => { - const httpStub = jest.spyOn(http, 'post').mockReturnValue(of({})); + const httpStub = jest.spyOn(http, 'post').mockReturnValue(of({ ltiIdToken: 'id-token', clientRegistrationId: 'client-id' })); const consoleSpy = jest.spyOn(console, 'error').mockImplementation(); expect(comp.isLaunching).toBeTrue(); comp.ngOnInit(); - expect(consoleSpy).toHaveBeenCalledOnce(); + expect(consoleSpy).toHaveBeenCalled(); expect(consoleSpy).toHaveBeenCalledWith('No LTI targetLinkUri received for a successful launch'); expect(httpStub).toHaveBeenCalledOnce(); expect(httpStub).toHaveBeenCalledWith('api/public/lti13/auth-login', expect.anything(), expect.anything()); @@ -114,7 +114,7 @@ describe('Lti13ExerciseLaunchComponent', () => { it('onInit success to call launch endpoint', () => { const targetLink = window.location.host + '/targetLink'; - const httpStub = jest.spyOn(http, 'post').mockReturnValue(of({ targetLinkUri: targetLink })); + const httpStub = jest.spyOn(http, 'post').mockReturnValue(of({ targetLinkUri: targetLink, ltiIdToken: 'id-token', clientRegistrationId: 'client-id' })); expect(comp.isLaunching).toBeTrue(); diff --git a/src/test/javascript/spec/component/lti/lti13-select-content.component.spec.ts b/src/test/javascript/spec/component/lti/lti13-select-content.component.spec.ts index 247056ddd681..f4715f47f7b3 100644 --- a/src/test/javascript/spec/component/lti/lti13-select-content.component.spec.ts +++ b/src/test/javascript/spec/component/lti/lti13-select-content.component.spec.ts @@ -1,7 +1,7 @@ import { Lti13SelectContentComponent } from 'app/lti/lti13-select-content.component'; -import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { ComponentFixture, TestBed, fakeAsync, tick, waitForAsync } from '@angular/core/testing'; import { ActivatedRoute } from '@angular/router'; -import { FormBuilder, ReactiveFormsModule } from '@angular/forms'; +import { FormBuilder, FormsModule, ReactiveFormsModule } from '@angular/forms'; import { of } from 'rxjs'; import { ArtemisTranslatePipe } from 'app/shared/pipes/artemis-translate.pipe'; import { SafeResourceUrlPipe } from 'app/shared/pipes/safe-resource-url.pipe'; @@ -23,7 +23,7 @@ describe('Lti13SelectContentComponent', () => { }; TestBed.configureTestingModule({ - imports: [ReactiveFormsModule], + imports: [ReactiveFormsModule, FormsModule], declarations: [Lti13SelectContentComponent, MockPipe(ArtemisTranslatePipe), MockPipe(SafeResourceUrlPipe)], providers: [FormBuilder, { provide: ActivatedRoute, useValue: routeMock }], }).compileComponents(); @@ -40,7 +40,7 @@ describe('Lti13SelectContentComponent', () => { expect(component).toBeTruthy(); }); - it('should initialize form on ngOnInit', () => { + it('should initialize form on ngOnInit', fakeAsync(() => { const jwt = 'jwt_token'; const id = 'id_token'; const deepLinkUri = 'http://example.com/deep_link'; @@ -57,22 +57,22 @@ describe('Lti13SelectContentComponent', () => { return null; } }); - const autoSubmitSpy = jest.spyOn(component, 'autoSubmitForm'); component.ngOnInit(); + tick(); expect(component.actionLink).toBe(deepLinkUri); expect(component.isLinking).toBeTrue(); - expect(autoSubmitSpy).toHaveBeenCalled(); - }); + })); - it('should not auto-submit form if parameters are missing', () => { + it('should not auto-submit form if parameters are missing', fakeAsync(() => { routeMock.snapshot.queryParamMap.get.mockReturnValue(null); const autoSubmitSpy = jest.spyOn(component, 'autoSubmitForm'); component.ngOnInit(); + tick(); expect(component.isLinking).toBeFalse(); expect(autoSubmitSpy).not.toHaveBeenCalled(); - }); + })); }); From 3214cc74ebfa05bcde7af094c6e94c36edf9001c Mon Sep 17 00:00:00 2001 From: Basak Akan Date: Thu, 16 Nov 2023 21:56:23 +0100 Subject: [PATCH 52/88] add more server tests --- .../security/Lti13LaunchFilterTest.java | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/src/test/java/de/tum/in/www1/artemis/security/Lti13LaunchFilterTest.java b/src/test/java/de/tum/in/www1/artemis/security/Lti13LaunchFilterTest.java index 08c2c618be17..74e69ac0cf28 100644 --- a/src/test/java/de/tum/in/www1/artemis/security/Lti13LaunchFilterTest.java +++ b/src/test/java/de/tum/in/www1/artemis/security/Lti13LaunchFilterTest.java @@ -33,6 +33,7 @@ import com.google.gson.JsonObject; import de.tum.in.www1.artemis.config.lti.CustomLti13Configurer; +import de.tum.in.www1.artemis.exception.LtiEmailAlreadyInUseException; import de.tum.in.www1.artemis.security.lti.Lti13LaunchFilter; import de.tum.in.www1.artemis.service.connectors.lti.Lti13Service; import uk.ac.ox.ctl.lti13.lti.Claims; @@ -183,6 +184,40 @@ void authenticatedLogin_serviceLaunchFailed() throws Exception { verify(httpResponse).sendError(eq(HttpStatus.INTERNAL_SERVER_ERROR.value()), any()); } + @Test + void emailAddressAlreadyInUse_serviceLaunchFailed() throws ServletException, IOException { + doReturn(false).when(authentication).isAuthenticated(); + doThrow(new LtiEmailAlreadyInUseException()).when(lti13Service).performLaunch(any(), any()); + + doReturn(CustomLti13Configurer.LTI13_LOGIN_PATH).when(httpRequest).getServletPath(); + doReturn(oidcToken).when(defaultFilter).attemptAuthentication(any(), any()); + initValidIdToken(); + + launchFilter.doFilter(httpRequest, httpResponse, filterChain); + + verify(httpResponse).sendError(eq(HttpStatus.UNAUTHORIZED.value()), any()); + verify(httpResponse).setHeader("TargetLinkUri", targetLinkUri); + verify(httpResponse).setHeader("ltiIdToken", null); + verify(httpResponse).setHeader("clientRegistrationId", "some-registration"); + } + + @Test + void emailAddressAlreadyInUse_serviceDeepLinkingFailed() throws ServletException, IOException { + doReturn(false).when(authentication).isAuthenticated(); + doThrow(new LtiEmailAlreadyInUseException()).when(lti13Service).startDeepLinking(any(), any()); + + doReturn(CustomLti13Configurer.LTI13_LOGIN_PATH).when(httpRequest).getServletPath(); + doReturn(oidcToken).when(defaultFilter).attemptAuthentication(any(), any()); + initValidTokenForDeepLinking(); + + launchFilter.doFilter(httpRequest, httpResponse, filterChain); + + verify(httpResponse).sendError(eq(HttpStatus.UNAUTHORIZED.value()), any()); + verify(httpResponse).setHeader("TargetLinkUri", "https://any-artemis-domain.org/lti/deep-linking/121"); + verify(httpResponse).setHeader("ltiIdToken", null); + verify(httpResponse).setHeader("clientRegistrationId", "some-registration"); + } + private JsonObject getMockJsonObject(boolean isDeepLinkingRequest) throws IOException, ServletException { doReturn(true).when(authentication).isAuthenticated(); doReturn(CustomLti13Configurer.LTI13_LOGIN_PATH).when(httpRequest).getServletPath(); From da2f8523e64270e1f440e3075fe04161572fb347 Mon Sep 17 00:00:00 2001 From: Basak Akan Date: Fri, 17 Nov 2023 10:11:41 +0100 Subject: [PATCH 53/88] improve server test --- .../service/connectors/lti/Lti13Service.java | 2 +- .../in/www1/artemis/web/rest/LtiResource.java | 9 ++ .../in/www1/artemis/LtiIntegrationTest.java | 104 +++++++++++++++++- .../artemis/connectors/Lti13ServiceTest.java | 6 +- 4 files changed, 112 insertions(+), 9 deletions(-) diff --git a/src/main/java/de/tum/in/www1/artemis/service/connectors/lti/Lti13Service.java b/src/main/java/de/tum/in/www1/artemis/service/connectors/lti/Lti13Service.java index c0dc9568c0dc..0a18f27840d6 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/connectors/lti/Lti13Service.java +++ b/src/main/java/de/tum/in/www1/artemis/service/connectors/lti/Lti13Service.java @@ -70,7 +70,7 @@ public class Lti13Service { public Lti13Service(UserRepository userRepository, ExerciseRepository exerciseRepository, CourseRepository courseRepository, Lti13ResourceLaunchRepository launchRepository, LtiService ltiService, ResultRepository resultRepository, Lti13TokenRetriever tokenRetriever, OnlineCourseConfigurationService onlineCourseConfigurationService, - RestTemplate restTemplate, ArtemisAuthenticationProvider artemisAuthenticationProvider, LtiDeepLinkingService ltiDeepLinkingService) { + RestTemplate restTemplate, ArtemisAuthenticationProvider artemisAuthenticationProvider) { this.userRepository = userRepository; this.exerciseRepository = exerciseRepository; this.courseRepository = courseRepository; diff --git a/src/main/java/de/tum/in/www1/artemis/web/rest/LtiResource.java b/src/main/java/de/tum/in/www1/artemis/web/rest/LtiResource.java index f0c0b6408a8f..1fc443ada4f4 100644 --- a/src/main/java/de/tum/in/www1/artemis/web/rest/LtiResource.java +++ b/src/main/java/de/tum/in/www1/artemis/web/rest/LtiResource.java @@ -17,6 +17,7 @@ import de.tum.in.www1.artemis.service.AuthorizationCheckService; import de.tum.in.www1.artemis.service.connectors.lti.LtiDeepLinkingService; import de.tum.in.www1.artemis.service.connectors.lti.LtiDynamicRegistrationService; +import de.tum.in.www1.artemis.web.rest.errors.BadRequestAlertException; /** * REST controller to handle LTI13 launches. @@ -67,6 +68,14 @@ public void lti13DynamicRegistration(@PathVariable Long courseId, @RequestParam( @EnforceAtLeastInstructor public ResponseEntity lti13DeepLinking(@PathVariable Long courseId, @RequestParam(name = "exerciseId") String exerciseId, @RequestParam(name = "ltiIdToken") String ltiIdToken, @RequestParam(name = "clientRegistrationId") String clientRegistrationId) throws ParseException { + + Course course = courseRepository.findByIdWithEagerOnlineCourseConfigurationElseThrow(courseId); + authCheckService.checkHasAtLeastRoleInCourseElseThrow(Role.INSTRUCTOR, course, null); + + if (!course.isOnlineCourse() || course.getOnlineCourseConfiguration() == null) { + throw new BadRequestAlertException("LTI is not configured for this course", "LTI", "ltiNotConfigured"); + } + OidcIdToken idToken = new OidcIdToken(ltiIdToken, null, null, SignedJWT.parse(ltiIdToken).getJWTClaimsSet().getClaims()); String targetLink = ltiDeepLinkingService.performDeepLinking(idToken, clientRegistrationId, courseId, Long.valueOf(exerciseId)); diff --git a/src/test/java/de/tum/in/www1/artemis/LtiIntegrationTest.java b/src/test/java/de/tum/in/www1/artemis/LtiIntegrationTest.java index fead73dba7a3..e4761ee2e83f 100644 --- a/src/test/java/de/tum/in/www1/artemis/LtiIntegrationTest.java +++ b/src/test/java/de/tum/in/www1/artemis/LtiIntegrationTest.java @@ -7,10 +7,12 @@ import java.net.URISyntaxException; import java.time.ZonedDateTime; import java.time.temporal.ChronoUnit; -import java.util.Set; +import java.util.*; import java.util.regex.Matcher; import java.util.regex.Pattern; +import javax.crypto.SecretKey; + import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; @@ -28,6 +30,8 @@ import org.springframework.util.MultiValueMap; import org.springframework.web.util.UriComponentsBuilder; +import com.nimbusds.jose.jwk.JWK; + import de.tum.in.www1.artemis.course.CourseUtilService; import de.tum.in.www1.artemis.domain.Course; import de.tum.in.www1.artemis.domain.OnlineCourseConfiguration; @@ -38,7 +42,11 @@ import de.tum.in.www1.artemis.repository.CourseRepository; import de.tum.in.www1.artemis.repository.ProgrammingExerciseRepository; import de.tum.in.www1.artemis.repository.UserRepository; +import de.tum.in.www1.artemis.security.OAuth2JWKSService; import de.tum.in.www1.artemis.user.UserUtilService; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.SignatureAlgorithm; +import io.jsonwebtoken.security.Keys; class LtiIntegrationTest extends AbstractSpringIntegrationBambooBitbucketJiraTest { @@ -371,6 +379,17 @@ void deepLinkingFailsForNonOnlineCourse() throws Exception { request.postWithoutResponseBody("/api/lti13/dynamic-registration/" + course.getId(), HttpStatus.BAD_REQUEST, params); } + @Test + @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") + void deepLinkingFailsAsInstructor() throws Exception { + var oAuth2JWKSService = mock(OAuth2JWKSService.class); + String jwkJsonString = "{\"kty\":\"RSA\",\"d\":\"base64-encoded-value\",\"e\":\"AQAB\",\"use\":\"sig\",\"kid\":\"123456\",\"alg\":\"RS256\",\"n\":\"base64-encoded-value\"}"; + when(oAuth2JWKSService.getJWK(any())).thenReturn(JWK.parse(jwkJsonString)); + var params = getDeepLinkingRequestParams(); + + request.postWithoutResponseBody("/api/lti13/dynamic-registration/" + course.getId(), HttpStatus.BAD_REQUEST, params); + } + private void assertParametersExistingStudent(MultiValueMap parameters) { assertThat(parameters.getFirst("initialize")).isNull(); assertThat(parameters.getFirst("ltiSuccessLoginRequired")).isNotNull(); @@ -381,11 +400,90 @@ private void assertParametersNewStudent(MultiValueMap parameters assertThat(parameters.getFirst("ltiSuccessLoginRequired")).isNull(); } - private static LinkedMultiValueMap getDeepLinkingRequestParams() { + private LinkedMultiValueMap getDeepLinkingRequestParams() { var params = new LinkedMultiValueMap(); params.add("exerciseId", "155"); - params.add("ltiIdToken", "id-token"); + params.add("ltiIdToken", createJwtForTest().toString()); params.add("clientRegistrationId", "registration-id"); return params; } + + private String createJwtForTest() { + SecretKey key = Keys.secretKeyFor(SignatureAlgorithm.HS256); + + Map claims = prepareTokenClaims(); + + return Jwts.builder().setClaims(claims).signWith(key, SignatureAlgorithm.HS256).compact(); + } + + private Map prepareTokenClaims() { + Map claims = new HashMap<>(); + + claims.put("iss", "https://platform.example.org"); + claims.put("sub", "a6d5c443-1f51-4783-ba1a-7686ffe3b54a"); + claims.put("aud", List.of("962fa4d8-bcbf-49a0-94b2-2de05ad274af")); + claims.put("exp", new Date(System.currentTimeMillis() + 3600_000)); // Token is valid for 1 hour + claims.put("iat", new Date(System.currentTimeMillis())); + claims.put("azp", "962fa4d8-bcbf-49a0-94b2-2de05ad274af"); + claims.put("nonce", "fc5fdc6d-5dd6-47f4-b2c9-5d1216e9b771"); + claims.put("name", "Ms Jane Marie Doe"); + claims.put("given_name", "Jane"); + claims.put("family_name", "Doe"); + claims.put("middle_name", "Marie"); + claims.put("picture", "https://example.org/jane.jpg"); + claims.put("email", "jane@example.org"); + claims.put("locale", "en-US"); + + // LTI specific claims + claims.put("https://purl.imsglobal.org/spec/lti/claim/deployment_id", "07940580-b309-415e-a37c-914d387c1150"); + claims.put("https://purl.imsglobal.org/spec/lti/claim/message_type", "LtiDeepLinkingRequest"); + claims.put("https://purl.imsglobal.org/spec/lti/claim/version", "1.3.0"); + claims.put("https://purl.imsglobal.org/spec/lti/claim/roles", + Arrays.asList("http://purl.imsglobal.org/vocab/lis/v2/membership#Instructor", "http://purl.imsglobal.org/vocab/lis/v2/institution/person#Faculty")); + + // Context claim + Map contextClaim = new HashMap<>(); + contextClaim.put("id", "c1d887f0-a1a3-4bca-ae25-c375edcc131a"); + contextClaim.put("label", "ECON 101"); + contextClaim.put("title", "Economics as a Social Science"); + contextClaim.put("type", List.of("CourseOffering")); + claims.put("https://purl.imsglobal.org/spec/lti/claim/context", contextClaim); + + // Tool platform claim + Map toolPlatformClaim = new HashMap<>(); + toolPlatformClaim.put("contact_email", "support@example.org"); + toolPlatformClaim.put("description", "An Example Tool Platform"); + toolPlatformClaim.put("name", "Example Tool Platform"); + toolPlatformClaim.put("url", "https://example.org"); + toolPlatformClaim.put("product_family_code", "example.org"); + toolPlatformClaim.put("version", "1.0"); + claims.put("https://purl.imsglobal.org/spec/lti/claim/tool_platform", toolPlatformClaim); + + // Launch presentation claim + Map launchPresentationClaim = new HashMap<>(); + launchPresentationClaim.put("document_target", "iframe"); + launchPresentationClaim.put("height", 320); + launchPresentationClaim.put("width", 240); + claims.put("https://purl.imsglobal.org/spec/lti/claim/launch_presentation", launchPresentationClaim); + + // Custom claim + Map customClaim = new HashMap<>(); + customClaim.put("myCustom", "123"); + claims.put("https://purl.imsglobal.org/spec/lti/claim/custom", customClaim); + + // Deep linking settings claim + Map deepLinkingSettingsClaim = new HashMap<>(); + deepLinkingSettingsClaim.put("deep_link_return_url", "https://platform.example/deep_links"); + deepLinkingSettingsClaim.put("accept_types", Arrays.asList("link", "file", "html", "ltiResourceLink", "image")); + deepLinkingSettingsClaim.put("accept_media_types", "image/*,text/html"); + deepLinkingSettingsClaim.put("accept_presentation_document_targets", Arrays.asList("iframe", "window", "embed")); + deepLinkingSettingsClaim.put("accept_multiple", true); + deepLinkingSettingsClaim.put("auto_create", true); + deepLinkingSettingsClaim.put("title", "This is the default title"); + deepLinkingSettingsClaim.put("text", "This is the default text"); + deepLinkingSettingsClaim.put("data", "csrftoken:c7fbba78-7b75-46e3-9201-11e6d5f36f53"); + claims.put("https://purl.imsglobal.org/spec/lti-dl/claim/deep_linking_settings", deepLinkingSettingsClaim); + + return claims; + } } diff --git a/src/test/java/de/tum/in/www1/artemis/connectors/Lti13ServiceTest.java b/src/test/java/de/tum/in/www1/artemis/connectors/Lti13ServiceTest.java index 4e5258616a78..a4e9735ccfbe 100644 --- a/src/test/java/de/tum/in/www1/artemis/connectors/Lti13ServiceTest.java +++ b/src/test/java/de/tum/in/www1/artemis/connectors/Lti13ServiceTest.java @@ -37,7 +37,6 @@ import de.tum.in.www1.artemis.security.lti.Lti13TokenRetriever; import de.tum.in.www1.artemis.service.OnlineCourseConfigurationService; import de.tum.in.www1.artemis.service.connectors.lti.Lti13Service; -import de.tum.in.www1.artemis.service.connectors.lti.LtiDeepLinkingService; import de.tum.in.www1.artemis.service.connectors.lti.LtiService; import de.tum.in.www1.artemis.web.rest.errors.BadRequestAlertException; import de.tum.in.www1.artemis.web.rest.errors.EntityNotFoundException; @@ -77,9 +76,6 @@ class Lti13ServiceTest { @Mock private ArtemisAuthenticationProvider artemisAuthenticationProvider; - @Mock - private LtiDeepLinkingService ltiDeepLinkingService; - private OidcIdToken oidcIdToken; private String clientRegistrationId; @@ -92,7 +88,7 @@ class Lti13ServiceTest { void init() { closeable = MockitoAnnotations.openMocks(this); lti13Service = new Lti13Service(userRepository, exerciseRepository, courseRepository, launchRepository, ltiService, resultRepository, tokenRetriever, - onlineCourseConfigurationService, restTemplate, artemisAuthenticationProvider, ltiDeepLinkingService); + onlineCourseConfigurationService, restTemplate, artemisAuthenticationProvider); clientRegistrationId = "clientId"; onlineCourseConfiguration = new OnlineCourseConfiguration(); onlineCourseConfiguration.setUserPrefix("prefix"); From fdf68c3220467a8c38743b349f2e6f7f140bdf4b Mon Sep 17 00:00:00 2001 From: Basak Akan Date: Fri, 17 Nov 2023 11:31:57 +0100 Subject: [PATCH 54/88] improve deep-linking server test --- .../in/www1/artemis/LtiIntegrationTest.java | 22 +++++++++---------- 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/src/test/java/de/tum/in/www1/artemis/LtiIntegrationTest.java b/src/test/java/de/tum/in/www1/artemis/LtiIntegrationTest.java index e4761ee2e83f..d6cfdcb915f4 100644 --- a/src/test/java/de/tum/in/www1/artemis/LtiIntegrationTest.java +++ b/src/test/java/de/tum/in/www1/artemis/LtiIntegrationTest.java @@ -20,6 +20,7 @@ import org.mockito.Mockito; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.test.mock.mockito.SpyBean; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; @@ -33,15 +34,10 @@ import com.nimbusds.jose.jwk.JWK; import de.tum.in.www1.artemis.course.CourseUtilService; -import de.tum.in.www1.artemis.domain.Course; -import de.tum.in.www1.artemis.domain.OnlineCourseConfiguration; -import de.tum.in.www1.artemis.domain.ProgrammingExercise; -import de.tum.in.www1.artemis.domain.User; +import de.tum.in.www1.artemis.domain.*; import de.tum.in.www1.artemis.exception.ArtemisAuthenticationException; import de.tum.in.www1.artemis.exercise.programmingexercise.ProgrammingExerciseUtilService; -import de.tum.in.www1.artemis.repository.CourseRepository; -import de.tum.in.www1.artemis.repository.ProgrammingExerciseRepository; -import de.tum.in.www1.artemis.repository.UserRepository; +import de.tum.in.www1.artemis.repository.*; import de.tum.in.www1.artemis.security.OAuth2JWKSService; import de.tum.in.www1.artemis.user.UserUtilService; import io.jsonwebtoken.Jwts; @@ -67,6 +63,9 @@ class LtiIntegrationTest extends AbstractSpringIntegrationBambooBitbucketJiraTes @Autowired private CourseUtilService courseUtilService; + @SpyBean + private OAuth2JWKSService oAuth2JWKSService; + private ProgrammingExercise programmingExercise; private Course course; @@ -376,18 +375,17 @@ void deepLinkingFailsForNonOnlineCourse() throws Exception { var params = getDeepLinkingRequestParams(); - request.postWithoutResponseBody("/api/lti13/dynamic-registration/" + course.getId(), HttpStatus.BAD_REQUEST, params); + request.postWithoutResponseBody("/api/lti13/deep-linking/" + course.getId(), HttpStatus.BAD_REQUEST, params); } @Test @WithMockUser(username = TEST_PREFIX + "instructor1", roles = "INSTRUCTOR") - void deepLinkingFailsAsInstructor() throws Exception { - var oAuth2JWKSService = mock(OAuth2JWKSService.class); + void deepLinkingAsInstructor() throws Exception { String jwkJsonString = "{\"kty\":\"RSA\",\"d\":\"base64-encoded-value\",\"e\":\"AQAB\",\"use\":\"sig\",\"kid\":\"123456\",\"alg\":\"RS256\",\"n\":\"base64-encoded-value\"}"; when(oAuth2JWKSService.getJWK(any())).thenReturn(JWK.parse(jwkJsonString)); var params = getDeepLinkingRequestParams(); - request.postWithoutResponseBody("/api/lti13/dynamic-registration/" + course.getId(), HttpStatus.BAD_REQUEST, params); + request.postWithoutResponseBody("/api/lti13/deep-linking/" + course.getId(), HttpStatus.BAD_REQUEST, params); } private void assertParametersExistingStudent(MultiValueMap parameters) { @@ -402,7 +400,7 @@ private void assertParametersNewStudent(MultiValueMap parameters private LinkedMultiValueMap getDeepLinkingRequestParams() { var params = new LinkedMultiValueMap(); - params.add("exerciseId", "155"); + params.add("exerciseId", programmingExercise.getId().toString()); params.add("ltiIdToken", createJwtForTest().toString()); params.add("clientRegistrationId", "registration-id"); return params; From 45a91876d609f4d8ebbcc6d42a3241c268a4e558 Mon Sep 17 00:00:00 2001 From: Basak Akan Date: Fri, 17 Nov 2023 12:09:19 +0100 Subject: [PATCH 55/88] fix codacy results --- .../service/connectors/lti/LtiDeepLinkingService.java | 3 ++- .../de/tum/in/www1/artemis/web/rest/LtiResource.java | 6 ++++-- .../tum/in/www1/artemis/connectors/Lti13ServiceTest.java | 9 --------- 3 files changed, 6 insertions(+), 12 deletions(-) diff --git a/src/main/java/de/tum/in/www1/artemis/service/connectors/lti/LtiDeepLinkingService.java b/src/main/java/de/tum/in/www1/artemis/service/connectors/lti/LtiDeepLinkingService.java index 6159d0d31092..62d716b08bef 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/connectors/lti/LtiDeepLinkingService.java +++ b/src/main/java/de/tum/in/www1/artemis/service/connectors/lti/LtiDeepLinkingService.java @@ -122,8 +122,9 @@ private void validateDeepLinkingResponseSettings(String returnURL, String jwt, S throw new BadRequestAlertException("Cannot find platform return URL", "LTI", "deepLinkReturnURLEmpty"); } - if (isEmptyString(deploymentId)) + if (isEmptyString(deploymentId)) { throw new BadRequestAlertException("Platform deployment id cannot be empty", "LTI", "deploymentIdEmpty"); + } } boolean isEmptyString(String string) { diff --git a/src/main/java/de/tum/in/www1/artemis/web/rest/LtiResource.java b/src/main/java/de/tum/in/www1/artemis/web/rest/LtiResource.java index 1fc443ada4f4..d676912a5a5d 100644 --- a/src/main/java/de/tum/in/www1/artemis/web/rest/LtiResource.java +++ b/src/main/java/de/tum/in/www1/artemis/web/rest/LtiResource.java @@ -60,8 +60,10 @@ public void lti13DynamicRegistration(@PathVariable Long courseId, @RequestParam( * for exercises within a course. The method populates content items with the provided course and exercise identifiers, * builds a deep linking response, and returns the target link URI in a JSON object. * - * @param courseId The identifier of the course for which the deep linking is being performed. - * @param exerciseId The identifier of the exercise to be included in the deep linking response. + * @param courseId The identifier of the course for which the deep linking is being performed. + * @param exerciseId The identifier of the exercise to be included in the deep linking response. + * @param ltiIdToken The token holding the deep linking information. + * @param clientRegistrationId The identifier online of the course configuration. * @return A ResponseEntity containing a JSON object with the 'targetLinkUri' property set to the deep linking response target link. */ @PostMapping("/lti13/deep-linking/{courseId}") diff --git a/src/test/java/de/tum/in/www1/artemis/connectors/Lti13ServiceTest.java b/src/test/java/de/tum/in/www1/artemis/connectors/Lti13ServiceTest.java index a4e9735ccfbe..9172d9ba3aa3 100644 --- a/src/test/java/de/tum/in/www1/artemis/connectors/Lti13ServiceTest.java +++ b/src/test/java/de/tum/in/www1/artemis/connectors/Lti13ServiceTest.java @@ -471,15 +471,6 @@ void startDeepLinkingCourseFound() { doReturn(Optional.of(exercise)).when(exerciseRepository).findById(exerciseId); doReturn(course).when(courseRepository).findByIdWithEagerOnlineCourseConfigurationElseThrow(courseId); - JsonObject mockSettings = new JsonObject(); - mockSettings.addProperty("deep_link_return_url", "test_return_url"); - when(oidcIdToken.getClaim(Claims.DEEP_LINKING_SETTINGS)).thenReturn(mockSettings); - - when(oidcIdToken.getClaim("iss")).thenReturn("http://artemis.com"); - when(oidcIdToken.getClaim("aud")).thenReturn("http://moodle.com"); - when(oidcIdToken.getClaim("exp")).thenReturn("12345"); - when(oidcIdToken.getClaim("iat")).thenReturn("test"); - when(oidcIdToken.getClaim("nonce")).thenReturn("1234-34535-abcbcbd"); when(oidcIdToken.getClaim(Claims.LTI_DEPLOYMENT_ID)).thenReturn("1"); when(oidcIdToken.getClaim(Claims.MESSAGE_TYPE)).thenReturn("LtiDeepLinkingRequest"); when(oidcIdToken.getClaim(Claims.TARGET_LINK_URI)).thenReturn("https://some-artemis-domain.org/lti/deep-linking/" + courseId); From 1e8a6ea93f8eacb3e245de36a592dc8c435bee2c Mon Sep 17 00:00:00 2001 From: Basak Akan Date: Tue, 21 Nov 2023 16:57:49 +0100 Subject: [PATCH 56/88] add login name to pop-up --- .../exercise-details/lti-initializer-modal.component.html | 5 +++++ .../exercise-details/lti-initializer-modal.component.ts | 1 + .../overview/exercise-details/lti-initializer.component.ts | 3 +++ src/main/webapp/i18n/de/lti.json | 1 + src/main/webapp/i18n/en/lti.json | 1 + 5 files changed, 11 insertions(+) diff --git a/src/main/webapp/app/overview/exercise-details/lti-initializer-modal.component.html b/src/main/webapp/app/overview/exercise-details/lti-initializer-modal.component.html index e1a75e902128..de41eaf78aa1 100644 --- a/src/main/webapp/app/overview/exercise-details/lti-initializer-modal.component.html +++ b/src/main/webapp/app/overview/exercise-details/lti-initializer-modal.component.html @@ -4,6 +4,11 @@