|
@@ -0,0 +1,189 @@
|
|
|
+package riomhaire.lti.core.business.commands;
|
|
|
+
|
|
|
+
|
|
|
+import com.auth0.jwt.JWT;
|
|
|
+import com.fasterxml.jackson.core.JsonProcessingException;
|
|
|
+import com.fasterxml.jackson.databind.ObjectMapper;
|
|
|
+import org.jose4j.jwk.PublicJsonWebKey;
|
|
|
+import org.jose4j.jwk.RsaJsonWebKey;
|
|
|
+import org.jose4j.jws.AlgorithmIdentifiers;
|
|
|
+import org.jose4j.jws.JsonWebSignature;
|
|
|
+import org.jose4j.keys.RsaKeyUtil;
|
|
|
+import org.jose4j.lang.JoseException;
|
|
|
+import org.slf4j.Logger;
|
|
|
+import org.slf4j.LoggerFactory;
|
|
|
+import org.springframework.web.servlet.ModelAndView;
|
|
|
+import riomhaire.lti.core.model.LTIMessage;
|
|
|
+import riomhaire.lti.core.model.interfaces.ApplicationRegistry;
|
|
|
+import riomhaire.lti.core.model.interfaces.Command;
|
|
|
+
|
|
|
+import java.io.DataOutputStream;
|
|
|
+import java.io.OutputStream;
|
|
|
+import java.net.HttpURLConnection;
|
|
|
+import java.net.URL;
|
|
|
+import java.net.URLConnection;
|
|
|
+import java.nio.charset.StandardCharsets;
|
|
|
+import java.security.KeyFactory;
|
|
|
+import java.security.NoSuchAlgorithmException;
|
|
|
+import java.security.spec.InvalidKeySpecException;
|
|
|
+import java.security.spec.PKCS8EncodedKeySpec;
|
|
|
+import java.time.Instant;
|
|
|
+import java.time.temporal.ChronoUnit;
|
|
|
+import java.util.*;
|
|
|
+
|
|
|
+public class StoreDeepLinkInLMSCommand implements Command<ModelAndView, ApplicationRegistry<ModelAndView, LTIMessage>, LTIMessage> {
|
|
|
+
|
|
|
+ public static final String LTI_CLAIM_DEPLOYMENT_ID = "https://purl.imsglobal.org/spec/lti/claim/deployment_id";
|
|
|
+ public static final String LTI_CLAIM_MESSAGE_TYPE = "https://purl.imsglobal.org/spec/lti/claim/message_type";
|
|
|
+ public static final String LTI_CLAIM_VERSION = "https://purl.imsglobal.org/spec/lti/claim/version";
|
|
|
+ public static final String LTI_DL_CLAIM_CONTENT_ITEMS = "https://purl.imsglobal.org/spec/lti-dl/claim/content_items";
|
|
|
+ public static final String LTI_CLAIM_TARGET_LINK_URI = "https://purl.imsglobal.org/spec/lti/claim/target_link_uri";
|
|
|
+ public static final String LTI_DL_CLAIM_DEEP_LINKING_SETTINGS = "https://purl.imsglobal.org/spec/lti-dl/claim/deep_linking_settings";
|
|
|
+ public static final String LTI_DEEP_LINK_RETURN_URL = "deep_link_return_url";
|
|
|
+ // Logger
|
|
|
+ private Logger log = LoggerFactory.getLogger(this.getClass());
|
|
|
+
|
|
|
+ @Override
|
|
|
+public ModelAndView execute(ApplicationRegistry<ModelAndView,LTIMessage> environment, LTIMessage message) {
|
|
|
+ var signedDLJwt="";
|
|
|
+
|
|
|
+ // OK steps are
|
|
|
+ // 1 - decode originating token
|
|
|
+ var token = message.getQueryParams().get("id_token");
|
|
|
+ var jwt = JWT.decode(token);
|
|
|
+
|
|
|
+ // Lookup client id and issuer
|
|
|
+ var issuer = jwt.getClaim("iss").asString();
|
|
|
+ var audience = jwt.getClaim("aud").asString();
|
|
|
+
|
|
|
+ // 2 - build response structure
|
|
|
+ var deepLinkResponse = new HashMap<String,Object>();
|
|
|
+ // Common stuff
|
|
|
+ deepLinkResponse.put("aud",issuer);
|
|
|
+ deepLinkResponse.put("iss", audience);
|
|
|
+
|
|
|
+ deepLinkResponse.put(LTI_CLAIM_MESSAGE_TYPE,"LtiDeepLinkingRequest");
|
|
|
+ deepLinkResponse.put(LTI_CLAIM_DEPLOYMENT_ID,jwt.getClaim(LTI_CLAIM_DEPLOYMENT_ID).asString());
|
|
|
+ deepLinkResponse.put(LTI_CLAIM_VERSION,"1.3.0");
|
|
|
+ deepLinkResponse.put("iat", Instant.now().getEpochSecond());
|
|
|
+ deepLinkResponse.put("exp", Instant.now().plus(1, ChronoUnit.HOURS).getEpochSecond());
|
|
|
+ deepLinkResponse.put("nonce", UUID.randomUUID().toString());
|
|
|
+ // Add the actual item
|
|
|
+ var contentItems = new ArrayList<Map<String,Object>>();
|
|
|
+ var contentLink = new HashMap<String,Object>();
|
|
|
+ var customParameters = new HashMap<String,Object>();
|
|
|
+ customParameters.put("resource_url",message.getQueryParams().get("url"));
|
|
|
+
|
|
|
+ contentLink.put("type","ltiResourceLink");
|
|
|
+ contentLink.put("title",message.getQueryParams().get("title"));
|
|
|
+ contentLink.put("url",jwt.getClaim(LTI_CLAIM_TARGET_LINK_URI).asString());
|
|
|
+ contentLink.put("custom",customParameters);
|
|
|
+
|
|
|
+ contentItems.add(contentLink);
|
|
|
+ deepLinkResponse.put(LTI_DL_CLAIM_CONTENT_ITEMS,contentItems);
|
|
|
+
|
|
|
+ // 3 - sign response structure using our private key
|
|
|
+ var optionalConfiguration = environment.toolConfigurationResolver().lookup();
|
|
|
+ if( !optionalConfiguration.isEmpty() ) {
|
|
|
+ // 4 - post a response to LMS (or set up for self posting form)
|
|
|
+// var targetUrl =
|
|
|
+ try {
|
|
|
+ signedDLJwt = signPayloadUsingOurPrivateKey(deepLinkResponse, optionalConfiguration);
|
|
|
+ } catch (Exception e) {
|
|
|
+ // Todo handle this
|
|
|
+ log.error(e.getMessage(),e);
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ // 5 - display 'success' page (maybe inc self post form)
|
|
|
+ var lmsDeepLinkClaim = jwt.getClaim(LTI_DL_CLAIM_DEEP_LINKING_SETTINGS);
|
|
|
+ var lmsTargetUrl = lmsDeepLinkClaim.asMap().get(LTI_DEEP_LINK_RETURN_URL).toString();
|
|
|
+ var state = message.getQueryParams().get("state").toString();
|
|
|
+ var mav = new ModelAndView("store-deep-link-in-lms");
|
|
|
+ mav.addObject("targetURL",lmsTargetUrl);
|
|
|
+ mav.addObject("state",state);
|
|
|
+ mav.addObject("JWT",signedDLJwt);
|
|
|
+
|
|
|
+ return mav;
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+ /**
|
|
|
+ * This method signs a series of claims using our public key
|
|
|
+ * @param deepLinkResponse
|
|
|
+ * @param optionalConfiguration
|
|
|
+ * @return
|
|
|
+ * @throws JsonProcessingException
|
|
|
+ * @throws JoseException
|
|
|
+ * @throws InvalidKeySpecException
|
|
|
+ */
|
|
|
+ private String signPayloadUsingOurPrivateKey(HashMap<String, Object> deepLinkResponse, Optional<riomhaire.lti.core.model.configuration.ToolConfiguration> optionalConfiguration) throws JsonProcessingException, JoseException, InvalidKeySpecException, NoSuchAlgorithmException {
|
|
|
+ JsonWebSignature jws = new JsonWebSignature();
|
|
|
+
|
|
|
+ // The payload of the JWS is JSON content of the JWT Claims
|
|
|
+ ObjectMapper serializer = new ObjectMapper();
|
|
|
+ String json = serializer.writerWithDefaultPrettyPrinter().writeValueAsString(deepLinkResponse);
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ jws.setPayload(json);
|
|
|
+
|
|
|
+ // The JWT is signed using the private key - we will use the 1st in the list
|
|
|
+ var jwkKey = optionalConfiguration.get().getJwkKeys()[0];
|
|
|
+
|
|
|
+ var privatePemString = new String(Base64.getDecoder().decode(jwkKey.getPrivateKey())); // default to 1st
|
|
|
+ var publicPemString = new String(Base64.getDecoder().decode(jwkKey.getPublicKey())); // default to 1st
|
|
|
+ var rsaJsonWebKey = (RsaJsonWebKey) PublicJsonWebKey.Factory.newPublicJwk(new RsaKeyUtil().fromPemEncoded(publicPemString));
|
|
|
+ // So target will know which to verify against
|
|
|
+ rsaJsonWebKey.setKeyId(jwkKey.getKeyId());
|
|
|
+ // Add private key after striping headers
|
|
|
+ privatePemString = privatePemString.replace("-----BEGIN RSA PRIVATE KEY-----", "");
|
|
|
+ privatePemString = privatePemString.replace("-----END RSA PRIVATE KEY-----", "");
|
|
|
+ privatePemString = privatePemString.replaceAll("\\s+","");
|
|
|
+
|
|
|
+ var keySpec = new PKCS8EncodedKeySpec(Base64.getDecoder().decode(privatePemString));
|
|
|
+ var keyFactory = KeyFactory.getInstance("RSA");
|
|
|
+ var privateKey = keyFactory.generatePrivate(keySpec); // error?
|
|
|
+
|
|
|
+ jws.setKey(privateKey);
|
|
|
+ jws.setKeyIdHeaderValue(jwkKey.getKeyId());
|
|
|
+ jws.setAlgorithmHeaderValue(AlgorithmIdentifiers.RSA_USING_SHA256);
|
|
|
+
|
|
|
+ // Sign the JWS and produce the compact serialization or the complete JWT/JWS
|
|
|
+ var signedJWT = jws.getCompactSerialization();
|
|
|
+ return signedJWT;
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+// private void postToLMS(String lmsTargetUrl, String signedDLJwt, String state) {
|
|
|
+// try {
|
|
|
+// URL url = new URL(lmsTargetUrl);
|
|
|
+// URLConnection con = null;
|
|
|
+// con = url.openConnection();
|
|
|
+// HttpURLConnection http = (HttpURLConnection)con;
|
|
|
+// http.setRequestMethod("POST"); // PUT is another valid option
|
|
|
+// http.setDoOutput(true);
|
|
|
+// StringBuilder formParams = new StringBuilder();
|
|
|
+// formParams.append("JWT=");
|
|
|
+// formParams.append(signedDLJwt);
|
|
|
+// formParams.append("&state=");
|
|
|
+// formParams.append(state);
|
|
|
+//
|
|
|
+// byte[] out = formParams.toString().getBytes(StandardCharsets.UTF_8);
|
|
|
+// int length = formParams.length();
|
|
|
+// http.setRequestProperty("Content-Type", "application/x-www-form-urlencoded; charset=UTF-8");
|
|
|
+// http.setRequestProperty("Content-Length", Integer.toString(length ));
|
|
|
+// http.setUseCaches(false);
|
|
|
+// http.connect();
|
|
|
+// try(DataOutputStream wr = new DataOutputStream(http.getOutputStream())) {
|
|
|
+// wr.write( out );
|
|
|
+// }
|
|
|
+// } catch (Throwable e) {
|
|
|
+// e.printStackTrace();
|
|
|
+// }
|
|
|
+// }
|
|
|
+
|
|
|
+}
|