diff --git a/Source/Plugins/Core/com.equella.core/resources/lang/i18n-resource-centre.properties b/Source/Plugins/Core/com.equella.core/resources/lang/i18n-resource-centre.properties
index 5c6a8a5025..7cb7198edf 100644
--- a/Source/Plugins/Core/com.equella.core/resources/lang/i18n-resource-centre.properties
+++ b/Source/Plugins/Core/com.equella.core/resources/lang/i18n-resource-centre.properties
@@ -1717,8 +1717,12 @@ export.added.summary.singleresource.contentpackage=This content package
export.added.summary.singleresource.resourcesummary=This summary page
export.added.summary.singleresourcemultilocations={0} was published to {1} locations
export.attachments=Include files
+export.authorization.newtab.description=This external connector needs to be launched in a new tab. Once the authorization flow is complete, close the new tab, and click OK in this dialog.
+export.authorization.newtab.launch=Launch Authorization in New Tab
+export.authorization.newtab.receipt=Authorization is complete. Please close this tab and click OK in the dialog in the original tab.
export.button.auth=Authorise external system
export.button.export=Add selected resources
+export.button.refreshcache=Refresh Course Cache
export.error.accessdenied=Access denied
export.error.nolocationsselected=No locations are selected to add resources to
export.error.noresourcesselected=No resources are selected to add
diff --git a/Source/Plugins/Core/com.equella.core/resources/view/blackboardrestconnector.ftl b/Source/Plugins/Core/com.equella.core/resources/view/blackboardrestconnector.ftl
index 346339d174..5884f9702f 100644
--- a/Source/Plugins/Core/com.equella.core/resources/view/blackboardrestconnector.ftl
+++ b/Source/Plugins/Core/com.equella.core/resources/view/blackboardrestconnector.ftl
@@ -7,31 +7,12 @@
<@css "blackboardconnector.css" />
-<@setting label='' help=b.key('bb.editor.help.installmodule')>
-
-@setting>
-
-<@ajax.div id="blackboardsetup">
+<@ajax.div id="blackboardrestsetup">
<#include "/com.tle.web.connectors@/field/serverurl.ftl" />
<#if m.testedUrl??>
- <@ajax.div id="testdiv">
-
- <@setting
- label=''
- error=m.errors["blackboardwebservice"]
- help=b.key('editor.help.testwebservice')
- rowStyle="testBlackboardRow">
-
- <@button section=s.testWebServiceButton showAs="verify" />
- <#if m.testWebServiceStatus??>
- ${b.key('bb.editor.label.testwebservice.' + m.testWebServiceStatus)}
- #if>
- @setting>
- @ajax.div>
-
<@setting label=b.key('blackboardrest.editor.label.apikey')
error=m.errors["apikey"]
help=b.key('blackboardrest.editor.help.apikey')
diff --git a/Source/Plugins/Core/com.equella.core/resources/view/dialog/lmsauth.ftl b/Source/Plugins/Core/com.equella.core/resources/view/dialog/lmsauth.ftl
index adaa047126..c5ef7f40ca 100644
--- a/Source/Plugins/Core/com.equella.core/resources/view/dialog/lmsauth.ftl
+++ b/Source/Plugins/Core/com.equella.core/resources/view/dialog/lmsauth.ftl
@@ -4,5 +4,24 @@
<@css "auth.css" />
-
-
\ No newline at end of file
+ <#if m.showReceipt >
+ ${b.key('export.authorization.newtab.receipt')}
+ <#else>
+ <#if m.showNewTabLauncher >
+ ${b.key('export.authorization.newtab.description')}
+
+
+
+
+ <#else>
+
+ #if>
+ #if>
+
diff --git a/Source/Plugins/Core/com.equella.core/resources/view/lmsexporter.ftl b/Source/Plugins/Core/com.equella.core/resources/view/lmsexporter.ftl
index 7657378dc6..0b8915ecb4 100644
--- a/Source/Plugins/Core/com.equella.core/resources/view/lmsexporter.ftl
+++ b/Source/Plugins/Core/com.equella.core/resources/view/lmsexporter.ftl
@@ -87,8 +87,13 @@
-
-
+
+ <#if m.courseCaching>
+
+ <@render s.refreshCourseCacheButton />
+
+ #if>
+
<@render s.publishButton />
@@ -96,4 +101,4 @@
#if>
@div>
-
\ No newline at end of file
+
diff --git a/Source/Plugins/Core/com.equella.core/src/com/tle/core/auditlog/AuditLogService.java b/Source/Plugins/Core/com.equella.core/src/com/tle/core/auditlog/AuditLogService.java
index 4bdb96df01..5ad9813da8 100644
--- a/Source/Plugins/Core/com.equella.core/src/com/tle/core/auditlog/AuditLogService.java
+++ b/Source/Plugins/Core/com.equella.core/src/com/tle/core/auditlog/AuditLogService.java
@@ -83,6 +83,14 @@ void logItemContentViewed(
void logItemPurged(Item item);
+ // Note: This is specific to the Blackboard REST connector,
+ // however, no other connector uses the audit log yet. Maybe need to refactor in the future
+ void logExternalConnectorUsed(
+ String externalConnectorUrl,
+ String requestLimit,
+ String requestRemaining,
+ String timeToReset);
+
void logGeneric(
String category, String type, String data1, String data2, String data3, String data4);
diff --git a/Source/Plugins/Core/com.equella.core/src/com/tle/core/auditlog/impl/AuditLogServiceImpl.java b/Source/Plugins/Core/com.equella.core/src/com/tle/core/auditlog/impl/AuditLogServiceImpl.java
index 4252ec8a6f..0019364ee1 100644
--- a/Source/Plugins/Core/com.equella.core/src/com/tle/core/auditlog/impl/AuditLogServiceImpl.java
+++ b/Source/Plugins/Core/com.equella.core/src/com/tle/core/auditlog/impl/AuditLogServiceImpl.java
@@ -50,6 +50,7 @@ public class AuditLogServiceImpl implements AuditLogService {
private static final String ENTITY_CATEGORY = "ENTITY";
private static final String SEARCH_CATEGORY = "SEARCH";
private static final String ITEM_CATEGORY = "ITEM";
+ private static final String EXTERNAL_CONN_CATEGORY = "EXTERNAL_CONNECTOR";
private static final String CREATED_TYPE = "CREATED";
private static final String MODIFIED_TYPE = "MODIFIED";
@@ -60,6 +61,8 @@ public class AuditLogServiceImpl implements AuditLogService {
private static final String SEARCH_FEDERATED_TYPE = "FEDERATED";
+ private static final String USED_TYPE = "USED";
+
private static final String TRUNCED = "...";
private PluginTracker extensionTracker;
@@ -197,6 +200,21 @@ public void logItemPurged(Item item) {
null);
}
+ @Override
+ public void logExternalConnectorUsed(
+ String externalConnectorUrl,
+ String requestLimit,
+ String requestRemaining,
+ String timeToReset) {
+ logGeneric(
+ EXTERNAL_CONN_CATEGORY,
+ USED_TYPE,
+ externalConnectorUrl,
+ requestLimit,
+ requestRemaining,
+ timeToReset);
+ }
+
private void logEntityGeneric(String type, long entityId) {
logGeneric(ENTITY_CATEGORY, type, CurrentUser.getUserID(), Long.toString(entityId), null, null);
}
diff --git a/Source/Plugins/Core/com.equella.core/src/com/tle/core/connectors/blackboard/BlackboardRESTConnectorConstants.java b/Source/Plugins/Core/com.equella.core/src/com/tle/core/connectors/blackboard/BlackboardRESTConnectorConstants.java
index 6ae4ae0d9b..71b4ee795e 100644
--- a/Source/Plugins/Core/com.equella.core/src/com/tle/core/connectors/blackboard/BlackboardRESTConnectorConstants.java
+++ b/Source/Plugins/Core/com.equella.core/src/com/tle/core/connectors/blackboard/BlackboardRESTConnectorConstants.java
@@ -18,16 +18,28 @@
package com.tle.core.connectors.blackboard;
-/** @author Aaron */
@SuppressWarnings("nls")
public final class BlackboardRESTConnectorConstants {
- private BlackboardRESTConnectorConstants() {
- throw new Error();
- }
+
+ public static final String AUTHENTICATIONCODE_SERVICE_URI_PATH =
+ "/learn/api/public/v1/oauth2/authorizationcode";
+
+ public static final String SESSION_KEY_USER_ID = "BbRest.UserId";
+ public static final String SESSION_COURSES = "BbRest.UserCourses";
+ public static final String SESSION_CODE = "BbRest.Code";
+ public static final String SESSION_TOKEN = "BbRest.Token";
public static final String CONNECTOR_TYPE = "blackboardrest";
- public static final String FIELD_TESTED_WEBSERVICE = "testedWebservice";
public static final String FIELD_API_KEY = "apiKey";
public static final String FIELD_API_SECRET = "apiSecret";
+
+ public static final String STATE_KEY_FORWARD_URL = "forwardUrl";
+ public static final String STATE_KEY_POSTFIX_KEY = "postfixKey";
+
+ public static final String AUTH_URL = "blackboardrestauth";
+
+ private BlackboardRESTConnectorConstants() {
+ throw new Error();
+ }
}
diff --git a/Source/Plugins/Core/com.equella.core/src/com/tle/core/connectors/blackboard/BlackboardRestAppContext.java b/Source/Plugins/Core/com.equella.core/src/com/tle/core/connectors/blackboard/BlackboardRestAppContext.java
new file mode 100644
index 0000000000..55f6011079
--- /dev/null
+++ b/Source/Plugins/Core/com.equella.core/src/com/tle/core/connectors/blackboard/BlackboardRestAppContext.java
@@ -0,0 +1,80 @@
+/*
+ * Licensed to The Apereo Foundation under one or more contributor license
+ * agreements. See the NOTICE file distributed with this work for additional
+ * information regarding copyright ownership.
+ *
+ * The Apereo Foundation licenses this file to you under the Apache License,
+ * Version 2.0, (the "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at:
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.tle.core.connectors.blackboard;
+
+import com.tle.annotation.Nullable;
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.net.URLEncoder;
+
+public class BlackboardRestAppContext {
+ private static final String STATE_PARAMETER = "state";
+ private static final String FIELD_REDIRECT_URI = "redirect_uri";
+ private static final String KEY_VALUE_RESPONSE_TYPE_CODE = "response_type=code";
+ private static final String FIELD_CLIENT_ID = "client_id";
+ private static final String FIELD_SCOPE = "scope";
+ private static final String VALUE_READ_WRITE_DELETE = "read write delete";
+
+ private final String _appId;
+ private final String _appKey;
+ private String _url;
+
+ /**
+ * Constructs a BlackboardRestAppContext with the provided application values
+ *
+ * @param appId The application ID provided by the key tool
+ * @param appKey The application key provided by the key tool
+ * @param url The url of the Bb instance
+ */
+ public BlackboardRestAppContext(String appId, String appKey, String url) {
+ _appId = appId;
+ _appKey = appKey;
+ if (url != null && url.endsWith("/")) {
+ _url = url.substring(0, url.lastIndexOf("/"));
+ } else {
+ _url = url;
+ }
+ }
+
+ public URI createWebUrlForAuthentication(URI redirectUrl, @Nullable String state) {
+ try {
+ URI uri =
+ new URI(
+ _url
+ + BlackboardRESTConnectorConstants.AUTHENTICATIONCODE_SERVICE_URI_PATH
+ + "?"
+ + buildAuthenticationCodeUriQueryString(redirectUrl, state));
+ return uri;
+ } catch (URISyntaxException e) {
+ return null;
+ }
+ }
+
+ private String buildAuthenticationCodeUriQueryString(URI callbackUri, @Nullable String state) {
+ String callbackUriString = callbackUri.toString();
+ String result = KEY_VALUE_RESPONSE_TYPE_CODE;
+ result += "&" + FIELD_REDIRECT_URI + "=" + callbackUriString;
+ result += "&" + FIELD_CLIENT_ID + "=" + _appId;
+ result += "&" + FIELD_SCOPE + "=" + URLEncoder.encode(VALUE_READ_WRITE_DELETE);
+ if (state != null) {
+ result += "&" + STATE_PARAMETER + "=" + URLEncoder.encode(state);
+ }
+ return result;
+ }
+}
diff --git a/Source/Plugins/Core/com.equella.core/src/com/tle/core/connectors/blackboard/beans/Availability.java b/Source/Plugins/Core/com.equella.core/src/com/tle/core/connectors/blackboard/beans/Availability.java
index d660416b5c..a87780a462 100644
--- a/Source/Plugins/Core/com.equella.core/src/com/tle/core/connectors/blackboard/beans/Availability.java
+++ b/Source/Plugins/Core/com.equella.core/src/com/tle/core/connectors/blackboard/beans/Availability.java
@@ -18,10 +18,11 @@
package com.tle.core.connectors.blackboard.beans;
+import java.io.Serializable;
import javax.xml.bind.annotation.XmlRootElement;
@XmlRootElement
-public class Availability {
+public class Availability implements Serializable {
public static final String YES = "Yes";
public static final String NO = "No";
private String available; // Yes
diff --git a/Source/Plugins/Core/com.equella.core/src/com/tle/core/connectors/blackboard/beans/Course.java b/Source/Plugins/Core/com.equella.core/src/com/tle/core/connectors/blackboard/beans/Course.java
index e242699fd7..a870710055 100644
--- a/Source/Plugins/Core/com.equella.core/src/com/tle/core/connectors/blackboard/beans/Course.java
+++ b/Source/Plugins/Core/com.equella.core/src/com/tle/core/connectors/blackboard/beans/Course.java
@@ -18,10 +18,11 @@
package com.tle.core.connectors.blackboard.beans;
+import java.io.Serializable;
import javax.xml.bind.annotation.XmlRootElement;
@XmlRootElement
-public class Course {
+public class Course implements Serializable {
private String id;
private String uuid;
private String externalId;
@@ -168,7 +169,7 @@ public void setGuestAccessUrl(String guestAccessUrl) {
}
@XmlRootElement
- public static class Enrollment {
+ public static class Enrollment implements Serializable {
private String type; // InstructorLed
public String getType() {
@@ -181,7 +182,7 @@ public void setType(String type) {
}
@XmlRootElement
- public static class Locale {
+ public static class Locale implements Serializable {
private Boolean force;
public Boolean getForce() {
diff --git a/Source/Plugins/Core/com.equella.core/src/com/tle/core/connectors/blackboard/beans/CourseByUser.java b/Source/Plugins/Core/com.equella.core/src/com/tle/core/connectors/blackboard/beans/CourseByUser.java
new file mode 100644
index 0000000000..6f0ddbc8e6
--- /dev/null
+++ b/Source/Plugins/Core/com.equella.core/src/com/tle/core/connectors/blackboard/beans/CourseByUser.java
@@ -0,0 +1,34 @@
+/*
+ * Licensed to The Apereo Foundation under one or more contributor license
+ * agreements. See the NOTICE file distributed with this work for additional
+ * information regarding copyright ownership.
+ *
+ * The Apereo Foundation licenses this file to you under the Apache License,
+ * Version 2.0, (the "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at:
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.tle.core.connectors.blackboard.beans;
+
+import javax.xml.bind.annotation.XmlRootElement;
+
+@XmlRootElement
+public class CourseByUser {
+ private Course course;
+
+ public Course getCourse() {
+ return course;
+ }
+
+ public void setCourse(Course course) {
+ this.course = course;
+ }
+}
diff --git a/Source/Plugins/Core/com.equella.core/src/com/tle/core/connectors/blackboard/beans/CoursesByUser.java b/Source/Plugins/Core/com.equella.core/src/com/tle/core/connectors/blackboard/beans/CoursesByUser.java
new file mode 100644
index 0000000000..f06cd5ccd9
--- /dev/null
+++ b/Source/Plugins/Core/com.equella.core/src/com/tle/core/connectors/blackboard/beans/CoursesByUser.java
@@ -0,0 +1,24 @@
+/*
+ * Licensed to The Apereo Foundation under one or more contributor license
+ * agreements. See the NOTICE file distributed with this work for additional
+ * information regarding copyright ownership.
+ *
+ * The Apereo Foundation licenses this file to you under the Apache License,
+ * Version 2.0, (the "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at:
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.tle.core.connectors.blackboard.beans;
+
+import javax.xml.bind.annotation.XmlRootElement;
+
+@XmlRootElement
+public class CoursesByUser extends PagedResults {}
diff --git a/Source/Plugins/Core/com.equella.core/src/com/tle/core/connectors/blackboard/beans/Token.java b/Source/Plugins/Core/com.equella.core/src/com/tle/core/connectors/blackboard/beans/Token.java
index 486d49ab41..4d4d396922 100644
--- a/Source/Plugins/Core/com.equella.core/src/com/tle/core/connectors/blackboard/beans/Token.java
+++ b/Source/Plugins/Core/com.equella.core/src/com/tle/core/connectors/blackboard/beans/Token.java
@@ -32,6 +32,12 @@ public class Token {
@JsonProperty("expires_in")
private Integer expiresIn;
+ @JsonProperty("scope")
+ private String scope;
+
+ @JsonProperty("user_id")
+ private String userId;
+
public String getAccessToken() {
return accessToken;
}
@@ -55,4 +61,20 @@ public Integer getExpiresIn() {
public void setExpiresIn(Integer expiresIn) {
this.expiresIn = expiresIn;
}
+
+ public String getScope() {
+ return scope;
+ }
+
+ public void setScope(String scope) {
+ this.scope = scope;
+ }
+
+ public String getUserId() {
+ return userId;
+ }
+
+ public void setUserId(String userId) {
+ this.userId = userId;
+ }
}
diff --git a/Source/Plugins/Core/com.equella.core/src/com/tle/core/connectors/blackboard/service/BlackboardRESTConnectorService.java b/Source/Plugins/Core/com.equella.core/src/com/tle/core/connectors/blackboard/service/BlackboardRESTConnectorService.java
index a067b2d106..989baf0c4e 100644
--- a/Source/Plugins/Core/com.equella.core/src/com/tle/core/connectors/blackboard/service/BlackboardRESTConnectorService.java
+++ b/Source/Plugins/Core/com.equella.core/src/com/tle/core/connectors/blackboard/service/BlackboardRESTConnectorService.java
@@ -18,6 +18,44 @@
package com.tle.core.connectors.blackboard.service;
+import com.tle.annotation.Nullable;
+import com.tle.common.connectors.entity.Connector;
import com.tle.core.connectors.service.ConnectorRepositoryImplementation;
-public interface BlackboardRESTConnectorService extends ConnectorRepositoryImplementation {}
+public interface BlackboardRESTConnectorService extends ConnectorRepositoryImplementation {
+ // TODO may need more method sigs
+
+ /**
+ * Admin setup function
+ *
+ * @param appId
+ * @param appKey
+ * @param brightspaceServerUrl
+ * @param forwardUrl
+ * @param postfixKey
+ * @return
+ */
+ String getAuthorisationUrl(
+ String appId,
+ String appKey,
+ String brightspaceServerUrl,
+ String forwardUrl,
+ @Nullable String postfixKey);
+
+ /**
+ * The connector object will need to store an encrypted admin token in the DB. Use this method to
+ * encrypt the one returned from Blackboard.
+ *
+ * @param token
+ * @return
+ */
+ String encrypt(String data);
+
+ String decrypt(String encryptedData);
+
+ void setToken(Connector connector, String value);
+
+ void setUserId(Connector connector, String value);
+
+ void removeCachedCoursesForConnector(Connector connector);
+}
diff --git a/Source/Plugins/Core/com.equella.core/src/com/tle/core/connectors/blackboard/service/impl/BlackboardRESTConnectorServiceImpl.java b/Source/Plugins/Core/com.equella.core/src/com/tle/core/connectors/blackboard/service/impl/BlackboardRESTConnectorServiceImpl.java
index a99c64a592..e9004cf588 100644
--- a/Source/Plugins/Core/com.equella.core/src/com/tle/core/connectors/blackboard/service/impl/BlackboardRESTConnectorServiceImpl.java
+++ b/Source/Plugins/Core/com.equella.core/src/com/tle/core/connectors/blackboard/service/impl/BlackboardRESTConnectorServiceImpl.java
@@ -20,19 +20,18 @@
import com.dytech.devlib.Base64;
import com.fasterxml.jackson.annotation.JsonInclude;
+import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
+import com.fasterxml.jackson.databind.node.ObjectNode;
import com.google.common.base.Strings;
import com.google.common.base.Throwables;
-import com.google.common.cache.CacheBuilder;
-import com.google.common.cache.CacheLoader;
-import com.google.common.cache.LoadingCache;
import com.tle.annotation.NonNullByDefault;
import com.tle.annotation.Nullable;
-import com.tle.beans.Institution;
import com.tle.beans.item.IItem;
import com.tle.beans.item.ViewableItemType;
+import com.tle.common.Check;
import com.tle.common.PathUtils;
import com.tle.common.connectors.ConnectorContent;
import com.tle.common.connectors.ConnectorCourse;
@@ -41,30 +40,35 @@
import com.tle.common.connectors.entity.Connector;
import com.tle.common.searching.SearchResults;
import com.tle.common.util.BlindSSLSocketFactory;
+import com.tle.core.auditlog.AuditLogService;
import com.tle.core.connectors.blackboard.BlackboardRESTConnectorConstants;
+import com.tle.core.connectors.blackboard.BlackboardRestAppContext;
import com.tle.core.connectors.blackboard.beans.*;
import com.tle.core.connectors.blackboard.service.BlackboardRESTConnectorService;
import com.tle.core.connectors.exception.LmsUserNotFoundException;
import com.tle.core.connectors.service.AbstractIntegrationConnectorRespository;
import com.tle.core.connectors.service.ConnectorRepositoryService;
import com.tle.core.connectors.service.ConnectorService;
-import com.tle.core.encryption.EncryptionService;
import com.tle.core.guice.Bind;
-import com.tle.core.institution.InstitutionCache;
import com.tle.core.institution.InstitutionService;
import com.tle.core.plugins.AbstractPluginService;
import com.tle.core.services.HttpService;
import com.tle.core.services.http.Request;
import com.tle.core.services.http.Response;
+import com.tle.core.services.user.UserSessionService;
import com.tle.core.settings.service.ConfigurationService;
+import com.tle.exceptions.AuthenticationException;
import com.tle.web.integration.Integration;
import com.tle.web.selection.SelectedResource;
import java.io.IOException;
import java.net.URI;
import java.util.ArrayList;
+import java.util.Arrays;
import java.util.List;
-import java.util.concurrent.ExecutionException;
-import java.util.concurrent.TimeUnit;
+import javax.crypto.Cipher;
+import javax.crypto.SecretKey;
+import javax.crypto.spec.IvParameterSpec;
+import javax.crypto.spec.SecretKeySpec;
import javax.inject.Inject;
import javax.inject.Singleton;
import org.apache.log4j.Level;
@@ -80,15 +84,22 @@ public class BlackboardRESTConnectorServiceImpl extends AbstractIntegrationConne
private static final String KEY_PFX =
AbstractPluginService.getMyPluginId(BlackboardRESTConnectorService.class) + ".";
- private static final String API_ROOT = "/learn/api/public/v1";
+ private static final String API_ROOT_V1 = "/learn/api/public/v1/";
+ private static final String API_ROOT_V3 = "/learn/api/public/v3/";
+
+ // Used to encrypt and decrypt state information (such as connector uuid)
+ // during the integration flows. Actual values are not important.
+ // TODO expose as a user configuration.
+ private static final byte[] SHAREPASS =
+ new byte[] {45, 12, -112, 2, 89, 97, 19, 74, 0, 24, -118, -2, 5, 108, 92, 7};
+ private static final IvParameterSpec INITVEC = new IvParameterSpec("thisis16byteslog".getBytes());
@Inject private HttpService httpService;
@Inject private ConfigurationService configService;
@Inject private ConnectorService connectorService;
- @Inject private EncryptionService encryptionService;
-
- private static final String TOKEN_KEY = "TOKEN";
- private InstitutionCache>> tokenCache;
+ @Inject private UserSessionService userSessionService;
+ @Inject private InstitutionService institutionService;
+ @Inject private AuditLogService auditService;
private static final ObjectMapper jsonMapper = new ObjectMapper();
private static final ObjectMapper prettyJsonMapper = new ObjectMapper();
@@ -109,77 +120,6 @@ public BlackboardRESTConnectorServiceImpl() {
Logger.getLogger("org.apache.commons.httpclient.HttpMethodDirector").setLevel(Level.ERROR);
}
- @Inject
- public void setInstitutionService(InstitutionService service) {
- tokenCache =
- service.newInstitutionAwareCache(
- new CacheLoader>>() {
- @Override
- public LoadingCache> load(Institution key) {
- // MaximumSize is set to 200, which would allow for 200 Blackboard REST connectors,
- // which should be more than enough for anyone.
- return CacheBuilder.newBuilder()
- .maximumSize(200)
- .expireAfterAccess(60, TimeUnit.MINUTES)
- .build(
- new CacheLoader>() {
- @Override
- public LoadingCache load(final String connectorUuid)
- throws Exception {
- // BB tokens last one hour, so no point holding onto it longer than
- // that. Of course, we need to handle the case
- // where we are still holding onto an expired token.
-
- return CacheBuilder.newBuilder()
- .expireAfterWrite(60, TimeUnit.MINUTES)
- .build(
- new CacheLoader() {
- @Override
- public String load(String fixedKey) {
- // fixedKey is ignored. It's always TOKEN
- final Connector connector =
- connectorService.getByUuid(connectorUuid);
- final String apiKey =
- connector.getAttribute(
- BlackboardRESTConnectorConstants.FIELD_API_KEY);
- final String apiSecret =
- encryptionService.decrypt(
- connector.getAttribute(
- BlackboardRESTConnectorConstants
- .FIELD_API_SECRET));
- final String b64 =
- new Base64()
- .encode((apiKey + ":" + apiSecret).getBytes())
- .replace("\n", "")
- .replace("\r", "");
-
- final Request req =
- new Request(
- PathUtils.urlPath(
- connector.getServerUrl(),
- "learn/api/public/v1/oauth2/token"));
- req.setMethod(Request.Method.POST);
- req.setMimeType("application/x-www-form-urlencoded");
- req.addHeader("Authorization", "Basic " + b64);
- req.setBody("grant_type=client_credentials");
- try (final Response resp =
- httpService.getWebContent(
- req, configService.getProxyDetails())) {
- final Token token =
- jsonMapper.readValue(
- resp.getInputStream(), Token.class);
- return token.getAccessToken();
- } catch (Exception e) {
- throw Throwables.propagate(e);
- }
- }
- });
- }
- });
- }
- });
- }
-
@Override
protected ViewableItemType getViewableItemType() {
return ViewableItemType.GENERIC;
@@ -197,12 +137,64 @@ protected boolean isRelativeUrls() {
@Override
public boolean isRequiresAuthentication(Connector connector) {
- return false;
+ try {
+ getToken(connector);
+ getUserId(connector);
+ LOGGER.debug(
+ "User session does not require auth for connector [" + connector.getUuid() + "]");
+ return false;
+ } catch (AuthenticationException ex) {
+ LOGGER.debug("User session requires auth for connector [" + connector.getUuid() + "]");
+ return true;
+ }
}
@Override
- public String getAuthorisationUrl(Connector connector, String forwardUrl, String authData) {
- return null;
+ public String getAuthorisationUrl(
+ Connector connector, String forwardUrl, @Nullable String authData) {
+ final BlackboardRestAppContext appContext = getAppContext(connector);
+ return getAuthorisationUrl(appContext, forwardUrl, authData, connector.getUuid());
+ }
+
+ @Override
+ public String getAuthorisationUrl(
+ String appId,
+ String appKey,
+ String bbServerUrl,
+ String forwardUrl,
+ @Nullable String postfixKey) {
+ final BlackboardRestAppContext appContext = getAppContext(appId, appKey, bbServerUrl);
+ return getAuthorisationUrl(appContext, forwardUrl, postfixKey, null);
+ }
+
+ private String getAuthorisationUrl(
+ BlackboardRestAppContext appContext,
+ String forwardUrl,
+ @Nullable String postfixKey,
+ String connectorUuid) {
+ LOGGER.trace("Requesting auth url for [" + connectorUuid + "]");
+ final ObjectMapper mapper = new ObjectMapper();
+ final ObjectNode stateJson = mapper.createObjectNode();
+ final String fUrl =
+ institutionService.getInstitutionUrl()
+ + "/api/connector/"
+ + stateJson.put(BlackboardRESTConnectorConstants.STATE_KEY_FORWARD_URL, forwardUrl);
+ if (postfixKey != null) {
+ stateJson.put(BlackboardRESTConnectorConstants.STATE_KEY_POSTFIX_KEY, postfixKey);
+ }
+ stateJson.put("connectorUuid", connectorUuid);
+ URI uri;
+ try {
+ uri =
+ appContext.createWebUrlForAuthentication(
+ URI.create(
+ institutionService.institutionalise(BlackboardRESTConnectorConstants.AUTH_URL)),
+ encrypt(mapper.writeValueAsString(stateJson)));
+ } catch (JsonProcessingException e) {
+ LOGGER.trace("Unable to provide the auth url for [" + connectorUuid + "]");
+ throw Throwables.propagate(e);
+ }
+ return uri.toString();
}
@Override
@@ -211,43 +203,72 @@ public String getCourseCode(Connector connector, String username, String courseI
return null;
}
+ /**
+ * Requests courses the user has access to from Blackboard and caches them (per user & connector)
+ *
+ * @param connector
+ * @param username
+ * @param editableOnly If true as list of courses that the user can add content to should be
+ * returned. If false then ALL courses will be returned.
+ * @param archived
+ * @param management Is this for manage resources?
+ * @return
+ */
@Override
public List getCourses(
Connector connector,
String username,
boolean editableOnly,
boolean archived,
- boolean management)
- throws LmsUserNotFoundException {
- final List list = new ArrayList<>();
+ boolean management) {
+ if (!isCoursesCached(connector)) {
+ String url =
+ API_ROOT_V1
+ + "users/"
+ + getUserIdType()
+ + getUserId(connector)
+ + "/courses?fields=course";
+
+ final List allCourses = new ArrayList<>();
+
+ // TODO (post new UI): a more generic way of doing paged results. Contents also does paging
+ CoursesByUser courses =
+ sendBlackboardData(connector, url, CoursesByUser.class, null, Request.Method.GET);
+ for (CourseByUser cbu : courses.getResults()) {
+ allCourses.add(cbu.getCourse());
+ }
+ Paging paging = courses.getPaging();
+
+ while (paging != null && paging.getNextPage() != null) {
+ courses =
+ sendBlackboardData(
+ connector, paging.getNextPage(), CoursesByUser.class, null, Request.Method.GET);
+ for (CourseByUser cbu : courses.getResults()) {
+ allCourses.add(cbu.getCourse());
+ }
+ paging = courses.getPaging();
+ }
- // FIXME: courses for current user...?
- // TODO - since v3400.8.0, this endpoint should use v2
- String url = API_ROOT + "/courses";
- /*
- if( !archived )
- {
- url += "&active=true";
- }*/
- final List allCourses = new ArrayList<>();
-
- // TODO: a more generic way of doing paged results. Contents also does paging
- Courses courses = sendBlackboardData(connector, url, Courses.class, null, Request.Method.GET);
- allCourses.addAll(courses.getResults());
- Paging paging = courses.getPaging();
-
- while (paging != null && paging.getNextPage() != null) {
- // FIXME: construct nextUrl from the base URL we know about and the relative URL from
- // getNextPage
- final String nextUrl = paging.getNextPage();
- courses = sendBlackboardData(connector, nextUrl, Courses.class, null, Request.Method.GET);
- allCourses.addAll(courses.getResults());
- paging = courses.getPaging();
+ setCachedCourses(connector, allCourses);
}
+ return getWrappedCachedCourses(connector, archived);
+ }
+ private boolean isCoursesCached(Connector connector) {
+ try {
+ return getCachedCourses(connector) != null;
+ } catch (AuthenticationException ae) {
+ return false;
+ }
+ }
+
+ private List getWrappedCachedCourses(
+ Connector connector, boolean includeArchived) {
+ final List list = new ArrayList<>();
+ final List allCourses = getCachedCourses(connector);
for (Course course : allCourses) {
// Display all courses if the archived flag is set, otherwise, just the 'available' ones
- if (archived || Availability.YES.equals(course.getAvailability().getAvailable())) {
+ if (includeArchived || Availability.YES.equals(course.getAvailability().getAvailable())) {
final ConnectorCourse cc = new ConnectorCourse(course.getId());
cc.setCourseCode(course.getCourseId());
cc.setName(course.getName());
@@ -255,14 +276,11 @@ public List getCourses(
list.add(cc);
}
}
-
return list;
}
private Course getCourseBean(Connector connector, String courseID) {
- // FIXME: courses for current user...?
- // TODO - since v3400.8.0, this endpoint should use v2
- String url = API_ROOT + "/courses/" + courseID;
+ String url = API_ROOT_V3 + "courses/" + courseID;
final Course course =
sendBlackboardData(connector, url, Course.class, null, Request.Method.GET);
@@ -270,9 +288,7 @@ private Course getCourseBean(Connector connector, String courseID) {
}
private Content getContentBean(Connector connector, String courseID, String folderID) {
- // FIXME: courses for current user...?
- // TODO - since v3400.8.0, this endpoint should use v2
- String url = API_ROOT + "/courses/" + courseID + "/contents/" + folderID;
+ String url = API_ROOT_V1 + "courses/" + courseID + "/contents/" + folderID;
final Content folder =
sendBlackboardData(connector, url, Content.class, null, Request.Method.GET);
@@ -283,26 +299,24 @@ private Content getContentBean(Connector connector, String courseID, String fold
public List getFoldersForCourse(
Connector connector, String username, String courseId, boolean management)
throws LmsUserNotFoundException {
- // FIXME: courses for current user...?
- final String url = API_ROOT + "/courses/" + courseId + "/contents";
+ final String url = API_ROOT_V1 + "courses/" + courseId + "/contents";
- return retrieveFolders(connector, url, username, courseId, management);
+ return retrieveFolders(connector, url, courseId, management);
}
@Override
public List getFoldersForFolder(
- Connector connector, String username, String courseId, String folderId, boolean management)
- throws LmsUserNotFoundException {
- // FIXME: courses for current user...?
- final String url = API_ROOT + "/courses/" + courseId + "/contents/" + folderId + "/children/";
+ Connector connector, String username, String courseId, String folderId, boolean management) {
+ // Username not needed to since we authenticate via 3LO.
+
+ final String url = API_ROOT_V1 + "courses/" + courseId + "/contents/" + folderId + "/children/";
- return retrieveFolders(connector, url, username, courseId, management);
+ return retrieveFolders(connector, url, courseId, management);
}
private List retrieveFolders(
- Connector connector, String url, String username, String courseId, boolean management) {
+ Connector connector, String url, String courseId, boolean management) {
final List list = new ArrayList<>();
-
final Contents contents =
sendBlackboardData(connector, url, Contents.class, null, Request.Method.GET);
final ConnectorCourse course = new ConnectorCourse(courseId);
@@ -316,7 +330,6 @@ private List retrieveFolders(
if (content.getAvailability() != null) {
cc.setAvailable(Availability.YES.equals(content.getAvailability().getAvailable()));
} else {
- // FIXME: Is this an appropriate default?
cc.setAvailable(false);
}
cc.setName(content.getTitle());
@@ -337,7 +350,7 @@ public ConnectorFolder addItemToCourse(
IItem> item,
SelectedResource selectedResource)
throws LmsUserNotFoundException {
- final String url = API_ROOT + "/courses/" + courseId + "/contents/" + folderId + "/children";
+ final String url = API_ROOT_V1 + "courses/" + courseId + "/contents/" + folderId + "/children";
final Integration.LmsLinkInfo linkInfo = getLmsLink(item, selectedResource);
final Integration.LmsLink lmsLink = linkInfo.getLmsLink();
@@ -365,15 +378,21 @@ public ConnectorFolder addItemToCourse(
content.setAvailability(availability);
sendBlackboardData(connector, url, null, content, Request.Method.POST);
- LOGGER.trace("Returning a courseId = [" + courseId + "], and folderId = [" + folderId + "]");
+ LOGGER.debug("Returning a courseId = [" + courseId + "], and folderId = [" + folderId + "]");
ConnectorFolder cf = new ConnectorFolder(folderId, new ConnectorCourse(courseId));
- // CB: Is there a better way to get the name of the folder and the course?
- // AH: Unfortunately not. We could cache them, but it probably isn't worth the additional
- // complexity
+ // TODO If folders end up being cached, pull the folder name from the cache.
Content folder = getContentBean(connector, courseId, folderId);
cf.setName(folder.getTitle());
- Course course = getCourseBean(connector, courseId);
- cf.getCourse().setName(course.getName());
+ final Course cachedCourse = getCachedCourse(connector, courseId);
+ if (cachedCourse == null) {
+ // This should never happen since the course will always be cached prior to adding content to
+ // it
+ final Course course = getCourseBean(connector, courseId);
+ cf.getCourse().setName(course.getName());
+ } else {
+ cf.getCourse().setName(cachedCourse.getName());
+ }
+
return cf;
}
@@ -435,6 +454,7 @@ public boolean moveContent(
@Override
public ConnectorTerminology getConnectorTerminology() {
+ LOGGER.debug("Requesting Bb REST connector terminology");
final ConnectorTerminology terms = new ConnectorTerminology();
terms.setShowArchived(getKey("finduses.showarchived"));
terms.setShowArchivedLocations(getKey("finduses.showarchived.courses"));
@@ -516,28 +536,38 @@ private T sendBlackboardData(
if (body.length() > 0) {
request.addHeader("Content-Type", "application/json");
}
- if (LOGGER.isTraceEnabled()) {
- LOGGER.trace("Sending " + prettyJson(body));
+ if (LOGGER.isDebugEnabled()) {
+ LOGGER.debug("Sending " + prettyJson(body));
}
+
// attach cached token. (Cache knows how to get a new one)
- request.addHeader("Authorization", "Bearer " + getToken(connector.getUuid()));
+ final String authHeaderValue = "Bearer " + getToken(connector);
+ LOGGER.trace(
+ "Setting Authorization header to ["
+ + authHeaderValue
+ + "]. Connector ["
+ + connector.getUuid()
+ + "]");
+
+ request.addHeader("Authorization", authHeaderValue);
try (Response response =
httpService.getWebContent(request, configService.getProxyDetails())) {
final String responseBody = response.getBody();
+ captureBlackboardRateLimitMetrics(uri.toString(), response);
final int code = response.getCode();
- if (LOGGER.isTraceEnabled()) {
- LOGGER.trace("Received from Blackboard (" + code + "):");
- LOGGER.trace(prettyJson(responseBody));
+ if (LOGGER.isDebugEnabled()) {
+ LOGGER.debug("Received from Blackboard (" + code + "):");
+ LOGGER.debug(prettyJson(responseBody));
}
if (code == 401 && firstTime) {
// Unauthorized request. Retry once to obtain a new token (assumes the current token is
// expired)
- LOGGER.trace(
+ LOGGER.debug(
"Received a 401 from Blackboard. Token for connector ["
+ connector.getUuid()
+ "] is likely expired. Retrying...");
- tokenCache.getCache().get(connector.getUuid()).invalidate(TOKEN_KEY);
+ removeCachedValuesForConnector(connector);
return sendBlackboardData(connector, path, returnType, data, method, false);
}
if (code >= 300) {
@@ -550,11 +580,21 @@ private T sendBlackboardData(
}
return null;
}
- } catch (ExecutionException | IOException ex) {
+ } catch (IOException ex) {
throw Throwables.propagate(ex);
}
}
+ private void captureBlackboardRateLimitMetrics(String url, Response response) {
+ final String xrlLimit = response.getHeader("X-Rate-Limit-Limit");
+ final String xrlRemaining = response.getHeader("X-Rate-Limit-Remaining");
+ final String xrlReset = response.getHeader("X-Rate-Limit-Reset");
+ LOGGER.debug("X-Rate-Limit-Limit = [" + xrlLimit + "]");
+ LOGGER.debug("X-Rate-Limit-Remaining = [" + xrlRemaining + "]");
+ LOGGER.debug("X-Rate-Limit-Reset = [" + xrlReset + "]");
+ auditService.logExternalConnectorUsed(url, xrlLimit, xrlRemaining, xrlReset);
+ }
+
@Nullable
private String prettyJson(@Nullable String json) {
if (Strings.isNullOrEmpty(json)) {
@@ -567,15 +607,172 @@ private String prettyJson(@Nullable String json) {
}
}
- private String getToken(String connectorUuid) {
- try {
- return tokenCache.getCache().get(connectorUuid).get(TOKEN_KEY);
- } catch (ExecutionException e) {
- throw Throwables.propagate(e);
+ private String getKey(String partKey) {
+ return KEY_PFX + "blackboardrest." + partKey;
+ }
+
+ private BlackboardRestAppContext getAppContext(Connector connector) {
+ if (LOGGER.isTraceEnabled()) {
+ LOGGER.trace(
+ "Blackboard REST connector attributes: "
+ + Arrays.toString(connector.getAttributes().keySet().toArray()));
}
+ return getAppContext(
+ connector.getAttribute(BlackboardRESTConnectorConstants.FIELD_API_KEY),
+ connector.getAttribute(BlackboardRESTConnectorConstants.FIELD_API_SECRET),
+ connector.getServerUrl());
}
- private String getKey(String partKey) {
- return KEY_PFX + "blackboardrest." + partKey;
+ private BlackboardRestAppContext getAppContext(String appId, String appKey, String serverUrl) {
+ return new BlackboardRestAppContext(appId, appKey, serverUrl);
+ }
+
+ @Override
+ public String encrypt(String data) {
+ LOGGER.debug("Encrypting data");
+ if (!Check.isEmpty(data)) {
+ try {
+ SecretKey key = new SecretKeySpec(SHAREPASS, "AES");
+ Cipher ecipher = Cipher.getInstance("AES/CBC/PKCS5Padding");
+ ecipher.init(Cipher.ENCRYPT_MODE, key, INITVEC);
+
+ // Encrypt
+ byte[] enc = ecipher.doFinal(data.getBytes());
+ return new Base64().encode(enc);
+
+ } catch (Exception e) {
+ throw new RuntimeException("Error encrypting", e);
+ }
+ }
+
+ return data;
+ }
+
+ @Override
+ public String decrypt(String encryptedData) {
+ LOGGER.debug("Decrypting data");
+ if (!Check.isEmpty(encryptedData)) {
+ try {
+ byte[] bytes = new Base64().decode(encryptedData);
+ SecretKey key = new SecretKeySpec(SHAREPASS, "AES");
+ Cipher ecipher = Cipher.getInstance("AES/CBC/PKCS5Padding");
+ ecipher.init(Cipher.DECRYPT_MODE, key, INITVEC);
+ return new String(ecipher.doFinal(bytes));
+ } catch (Exception e) {
+ throw new RuntimeException("Error decrypting ", e);
+ }
+ }
+
+ return encryptedData;
+ }
+
+ public String getToken(Connector connector) {
+ return getCachedSessionValue(connector, BlackboardRESTConnectorConstants.SESSION_TOKEN);
+ }
+
+ public String getUserId(Connector connector) {
+ return getCachedSessionValue(connector, BlackboardRESTConnectorConstants.SESSION_KEY_USER_ID);
+ }
+
+ private void removeCachedValuesForConnector(Connector connector) {
+ removeCachedSessionValue(connector, BlackboardRESTConnectorConstants.SESSION_TOKEN);
+ removeCachedSessionValue(connector, BlackboardRESTConnectorConstants.SESSION_KEY_USER_ID);
+ removeCachedCoursesForConnector(connector);
+ }
+
+ public void removeCachedCoursesForConnector(Connector connector) {
+ removeCachedSessionValue(connector, BlackboardRESTConnectorConstants.SESSION_COURSES);
+ }
+
+ private String getUserIdType() {
+ // According to the Bb Support team, accessing the REST APIs in this manner should always return
+ // a userid as a uuid
+ return "uuid:";
+ }
+
+ public void setToken(Connector connector, String token) {
+ setCachedSessionValue(connector, BlackboardRESTConnectorConstants.SESSION_TOKEN, token);
+ }
+
+ public void setUserId(Connector connector, String userId) {
+ setCachedSessionValue(connector, BlackboardRESTConnectorConstants.SESSION_KEY_USER_ID, userId);
+ }
+
+ private void setCachedCourses(Connector connector, List courses) {
+ final String key = BlackboardRESTConnectorConstants.SESSION_COURSES;
+ LOGGER.debug(
+ "Setting user session "
+ + key
+ + " for Bb REST connector ["
+ + connector.getUuid()
+ + "] - number of cached courses ["
+ + courses.size()
+ + "]");
+
+ userSessionService.setAttribute(connector.getUuid() + key, courses);
+ }
+
+ private List getCachedCourses(Connector connector) {
+ final String key = BlackboardRESTConnectorConstants.SESSION_COURSES;
+ final List cachedValue = userSessionService.getAttribute(connector.getUuid() + key);
+ if (cachedValue == null) {
+ LOGGER.debug(
+ "No user session " + key + " for Bb REST connector [" + connector.getUuid() + "]");
+ throw new AuthenticationException("User was not able to obtain cached " + key + ".");
+ }
+ LOGGER.debug(
+ "Found a user session "
+ + key
+ + " for Bb REST connector ["
+ + connector.getUuid()
+ + "] - number of courses returned ["
+ + cachedValue.size()
+ + "]");
+ return cachedValue;
+ }
+
+ private Course getCachedCourse(Connector connector, String courseId) {
+ final List cache = getCachedCourses(connector);
+ for (Course c : cache) {
+ if (c.getId().equals(courseId)) {
+ return c;
+ }
+ }
+ return null;
+ }
+
+ private String getCachedSessionValue(Connector connector, String key) {
+ final String cachedValue = userSessionService.getAttribute(connector.getUuid() + key);
+ if (cachedValue == null) {
+ LOGGER.debug(
+ "No user session " + key + " for Bb REST connector [" + connector.getUuid() + "]");
+ throw new AuthenticationException("User was not able to obtain cached " + key + ".");
+ }
+ logSensitiveDetails(
+ "Found a user session " + key + " for Bb REST connector [" + connector.getUuid() + "]",
+ " - value [" + cachedValue + "]");
+ return cachedValue;
+ }
+
+ private void setCachedSessionValue(Connector connector, String key, String value) {
+ logSensitiveDetails(
+ "Setting user session " + key + " for Bb REST connector [" + connector.getUuid() + "]",
+ " to [" + value + "]");
+ userSessionService.setAttribute(connector.getUuid() + key, value);
+ }
+
+ private void removeCachedSessionValue(Connector connector, String key) {
+ LOGGER.debug(
+ "Removing user session " + key + " for Bb REST connector [" + connector.getUuid() + "]");
+ userSessionService.removeAttribute(connector.getUuid() + key);
+ }
+
+ private void logSensitiveDetails(String msg, String sensitiveMsg) {
+ if (LOGGER.isTraceEnabled()) {
+ // NOTE: Use with care - exposes sensitive details. Only to be used for investigations
+ LOGGER.trace(msg + sensitiveMsg);
+ } else {
+ LOGGER.debug(msg);
+ }
}
}
diff --git a/Source/Plugins/Core/com.equella.core/src/com/tle/web/connectors/blackboard/editor/BlackboardRESTConnectorEditor.java b/Source/Plugins/Core/com.equella.core/src/com/tle/web/connectors/blackboard/editor/BlackboardRESTConnectorEditor.java
index 8cab19fc85..5f68da4b37 100644
--- a/Source/Plugins/Core/com.equella.core/src/com/tle/web/connectors/blackboard/editor/BlackboardRESTConnectorEditor.java
+++ b/Source/Plugins/Core/com.equella.core/src/com/tle/web/connectors/blackboard/editor/BlackboardRESTConnectorEditor.java
@@ -20,7 +20,6 @@
import com.tle.common.connectors.entity.Connector;
import com.tle.core.connectors.blackboard.BlackboardRESTConnectorConstants;
-import com.tle.core.connectors.blackboard.service.BlackboardRESTConnectorService;
import com.tle.core.connectors.service.ConnectorEditingBean;
import com.tle.core.encryption.EncryptionService;
import com.tle.core.entity.EntityEditingSession;
@@ -29,39 +28,26 @@
import com.tle.web.freemarker.FreemarkerFactory;
import com.tle.web.freemarker.annotations.ViewFactory;
import com.tle.web.sections.SectionInfo;
-import com.tle.web.sections.SectionTree;
import com.tle.web.sections.annotations.EventFactory;
-import com.tle.web.sections.annotations.EventHandlerMethod;
-import com.tle.web.sections.equella.annotation.PlugKey;
import com.tle.web.sections.events.RenderContext;
import com.tle.web.sections.events.RenderEventContext;
import com.tle.web.sections.events.js.EventGenerator;
-import com.tle.web.sections.render.Label;
import com.tle.web.sections.render.SectionRenderable;
-import com.tle.web.sections.standard.Button;
import com.tle.web.sections.standard.TextField;
import com.tle.web.sections.standard.annotations.Component;
import java.util.Map;
import javax.inject.Inject;
+import org.apache.log4j.Logger;
@SuppressWarnings("nls")
@Bind
public class BlackboardRESTConnectorEditor
extends AbstractConnectorEditorSection<
BlackboardRESTConnectorEditor.BlackboardRESTConnectorEditorModel> {
- @PlugKey("bb.editor.error.testwebservice.mandatory")
- private static Label LABEL_TEST_WEBSERVICE_MANDATORY;
+ private static final Logger LOGGER = Logger.getLogger(BlackboardRESTConnectorEditor.class);
- @PlugKey("editor.error.testwebservice.enteruser")
- private static Label LABEL_TEST_WEBSERVICE_ENTERUSER;
-
- @Inject private BlackboardRESTConnectorService blackboardService;
@Inject private EncryptionService encryptionService;
- @PlugKey("editor.button.testwebservice")
- @Component
- private Button testWebServiceButton;
-
@Component(name = "ak", stateful = false)
private TextField apiKey;
@@ -77,39 +63,9 @@ protected SectionRenderable renderFields(
return view.createResult("blackboardrestconnector.ftl", context);
}
- @Override
- public void registered(String id, SectionTree tree) {
- super.registered(id, tree);
-
- testWebServiceButton.setClickHandler(
- ajax.getAjaxUpdateDomFunction(
- tree, this, events.getEventHandler("testWebService"), "testdiv"));
- }
-
@Override
protected String getAjaxDivId() {
- return "blackboardsetup";
- }
-
- @EventHandlerMethod
- public void testWebService(SectionInfo info) {
- // final EntityEditingSession session = saveToSession(info);
- //
- //
- // final ConnectorEditingBean connector = session.getBean();
- //
- // final String result = blackboardService.testConnection(connector.getServerUrl(), "");
- // if( result == null )
- // {
- // connector.setAttribute(BlackboardRESTConnectorConstants.FIELD_TESTED_WEBSERVICE, true);
- // getModel(info).setTestWebServiceStatus("ok");
- // }
- // else
- // {
- // connector.setAttribute(BlackboardRESTConnectorConstants.FIELD_TESTED_WEBSERVICE, false);
- // getModel(info).setTestWebServiceStatus("fail");
- // session.getValidationErrors().put("blackboardwebservice", result);
- // }
+ return "blackboardrestsetup";
}
@Override
@@ -125,12 +81,7 @@ protected Connector createNewConnector() {
@Override
protected void customValidate(
SectionInfo info, ConnectorEditingBean connector, Map errors) {
- // FIXME: actual validation of key and secret
- // if( !connector.getAttribute(BlackboardRESTConnectorConstants.FIELD_TESTED_WEBSERVICE, false)
- // )
- // {
- // errors.put("blackboardwebservice", LABEL_TEST_WEBSERVICE_MANDATORY.getText());
- // }
+ // no op
}
@Override
@@ -140,12 +91,6 @@ protected void customLoad(SectionInfo info, ConnectorEditingBean connector) {
info,
encryptionService.decrypt(
connector.getAttribute(BlackboardRESTConnectorConstants.FIELD_API_SECRET)));
- final boolean testedWebservice =
- connector.getAttribute(BlackboardRESTConnectorConstants.FIELD_TESTED_WEBSERVICE, false);
- if (testedWebservice) {
- final BlackboardRESTConnectorEditorModel model = getModel(info);
- // model.setTestWebServiceStatus("ok");
- }
}
@Override
@@ -161,10 +106,6 @@ public Object instantiateModel(SectionInfo info) {
return new BlackboardRESTConnectorEditorModel();
}
- public Button getTestWebServiceButton() {
- return testWebServiceButton;
- }
-
public TextField getApiKey() {
return apiKey;
}
diff --git a/Source/Plugins/Core/com.equella.core/src/com/tle/web/connectors/blackboard/servlet/BlackboardRestOauthSignonServlet.java b/Source/Plugins/Core/com.equella.core/src/com/tle/web/connectors/blackboard/servlet/BlackboardRestOauthSignonServlet.java
new file mode 100644
index 0000000000..3ad327effe
--- /dev/null
+++ b/Source/Plugins/Core/com.equella.core/src/com/tle/web/connectors/blackboard/servlet/BlackboardRestOauthSignonServlet.java
@@ -0,0 +1,167 @@
+/*
+ * Licensed to The Apereo Foundation under one or more contributor license
+ * agreements. See the NOTICE file distributed with this work for additional
+ * information regarding copyright ownership.
+ *
+ * The Apereo Foundation licenses this file to you under the Apache License,
+ * Version 2.0, (the "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at:
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.tle.web.connectors.blackboard.servlet;
+
+import com.dytech.devlib.Base64;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.node.ObjectNode;
+import com.google.common.base.Throwables;
+import com.tle.annotation.NonNullByDefault;
+import com.tle.common.PathUtils;
+import com.tle.common.connectors.entity.Connector;
+import com.tle.core.connectors.blackboard.BlackboardRESTConnectorConstants;
+import com.tle.core.connectors.blackboard.beans.Token;
+import com.tle.core.connectors.blackboard.service.BlackboardRESTConnectorService;
+import com.tle.core.connectors.service.ConnectorService;
+import com.tle.core.encryption.EncryptionService;
+import com.tle.core.guice.Bind;
+import com.tle.core.institution.InstitutionService;
+import com.tle.core.services.HttpService;
+import com.tle.core.services.http.Request;
+import com.tle.core.services.http.Response;
+import com.tle.core.services.user.UserSessionService;
+import com.tle.core.settings.service.ConfigurationService;
+import com.tle.exceptions.AuthenticationException;
+import com.tle.web.oauth.response.ErrorResponse;
+import java.io.IOException;
+import javax.inject.Inject;
+import javax.inject.Singleton;
+import javax.servlet.ServletException;
+import javax.servlet.http.HttpServlet;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import org.apache.log4j.Logger;
+
+/** Served up at /blackboardrestauth */
+@SuppressWarnings("nls")
+@NonNullByDefault
+@Bind
+@Singleton
+public class BlackboardRestOauthSignonServlet extends HttpServlet {
+ private static final String STATE_CALLBACK_PARAMETER = "state";
+
+ private static final Logger LOGGER = Logger.getLogger(BlackboardRestOauthSignonServlet.class);
+ @Inject private HttpService httpService;
+ @Inject private ConnectorService connectorService;
+ @Inject private EncryptionService encryptionService;
+ @Inject private ConfigurationService configService;
+ @Inject private UserSessionService sessionService;
+ @Inject private BlackboardRESTConnectorService blackboardRestConnectorService;
+ @Inject private InstitutionService institutionService;
+
+ private static final ObjectMapper jsonMapper = new ObjectMapper();
+
+ @Override
+ protected void doGet(HttpServletRequest req, HttpServletResponse resp)
+ throws ServletException, IOException {
+ LOGGER.trace("Requesting OAuth Sign-on");
+ String postfixKey = "";
+ String connectorUuid = "";
+ String forwardUrl = null;
+ String state = req.getParameter(STATE_CALLBACK_PARAMETER);
+
+ if (state != null) {
+ ObjectNode stateJson =
+ (ObjectNode) new ObjectMapper().readTree(blackboardRestConnectorService.decrypt(state));
+ JsonNode forwardUrlNode =
+ stateJson.get(BlackboardRESTConnectorConstants.STATE_KEY_FORWARD_URL);
+ if (forwardUrlNode != null) {
+ forwardUrl = forwardUrlNode.asText();
+ }
+
+ JsonNode postfixKeyNode =
+ stateJson.get(BlackboardRESTConnectorConstants.STATE_KEY_POSTFIX_KEY);
+ if (postfixKeyNode != null) {
+ postfixKey = postfixKeyNode.asText();
+ }
+
+ JsonNode connectorUuidNode = stateJson.get("connectorUuid");
+ if (connectorUuidNode != null) {
+ connectorUuid = connectorUuidNode.asText();
+ }
+ }
+ String code = req.getParameter("code");
+ sessionService.setAttribute(BlackboardRESTConnectorConstants.SESSION_CODE + postfixKey, code);
+
+ // Ask for the token.
+ final Connector connector = connectorService.getByUuid(connectorUuid);
+ final String apiKey = connector.getAttribute(BlackboardRESTConnectorConstants.FIELD_API_KEY);
+ final String apiSecret =
+ encryptionService.decrypt(
+ connector.getAttribute(BlackboardRESTConnectorConstants.FIELD_API_SECRET));
+ final String b64 =
+ new Base64()
+ .encode((apiKey + ":" + apiSecret).getBytes())
+ .replace("\n", "")
+ .replace("\r", "");
+
+ final Request oauthReq =
+ new Request(
+ PathUtils.urlPath(
+ connector.getServerUrl(),
+ "learn/api/public/v1/oauth2/token?code="
+ + code
+ + "&redirect_uri="
+ + institutionService.institutionalise(
+ BlackboardRESTConnectorConstants.AUTH_URL)));
+ oauthReq.setMethod(Request.Method.POST);
+ oauthReq.setMimeType("application/x-www-form-urlencoded");
+ oauthReq.addHeader("Authorization", "Basic " + b64);
+ oauthReq.setBody("grant_type=authorization_code");
+ try (final Response resp2 =
+ httpService.getWebContent(oauthReq, configService.getProxyDetails())) {
+ if (resp2.isOk()) {
+ LOGGER.trace("Blackboard response: " + resp2.getBody());
+ final Token tokenJson = jsonMapper.readValue(resp2.getBody(), Token.class);
+ LOGGER.warn("Gathered Blackboard access token for [" + connectorUuid + "]");
+ blackboardRestConnectorService.setToken(connector, tokenJson.getAccessToken());
+ blackboardRestConnectorService.setUserId(connector, tokenJson.getUserId());
+
+ } else {
+ final ErrorResponse bbErr = jsonMapper.readValue(resp2.getBody(), ErrorResponse.class);
+ LOGGER.warn(
+ "Unable to gather Blackboard access token for ["
+ + connectorUuid
+ + "] - Code ["
+ + resp2.getCode()
+ + "] - Msg ["
+ + resp2.getMessage()
+ + "] - Body ["
+ + resp2.getBody()
+ + "]");
+ throw new AuthenticationException(
+ "Unable to authenticate with Blackboard - " + bbErr.getErrorDescription());
+ }
+ } catch (Exception e) {
+ LOGGER.warn(
+ "Unable to gather Blackboard access token for ["
+ + connectorUuid
+ + "] - "
+ + e.getMessage(),
+ e);
+ throw Throwables.propagate(e);
+ }
+
+ // close dialog OR redirect...
+ if (forwardUrl != null) {
+ resp.sendRedirect(forwardUrl);
+ }
+ }
+}
diff --git a/Source/Plugins/Core/com.equella.core/src/com/tle/web/connectors/dialog/LMSAuthDialog.java b/Source/Plugins/Core/com.equella.core/src/com/tle/web/connectors/dialog/LMSAuthDialog.java
index 87108059e1..a9f923e755 100644
--- a/Source/Plugins/Core/com.equella.core/src/com/tle/web/connectors/dialog/LMSAuthDialog.java
+++ b/Source/Plugins/Core/com.equella.core/src/com/tle/web/connectors/dialog/LMSAuthDialog.java
@@ -22,9 +22,11 @@
import com.tle.annotation.NonNullByDefault;
import com.tle.annotation.Nullable;
import com.tle.common.connectors.entity.Connector;
+import com.tle.core.connectors.blackboard.BlackboardRESTConnectorConstants;
import com.tle.core.connectors.service.ConnectorRepositoryService;
import com.tle.core.connectors.service.ConnectorService;
import com.tle.core.guice.Bind;
+import com.tle.core.services.user.UserSessionService;
import com.tle.web.freemarker.FreemarkerFactory;
import com.tle.web.freemarker.annotations.ViewFactory;
import com.tle.web.sections.SectionInfo;
@@ -41,16 +43,20 @@
import com.tle.web.sections.render.SectionRenderable;
import com.tle.web.sections.standard.dialog.model.DialogModel;
import javax.inject.Inject;
+import org.apache.log4j.Logger;
/** @author Aaron */
@NonNullByDefault
@Bind
public class LMSAuthDialog extends AbstractOkayableDialog {
+ private static final Logger LOGGER = Logger.getLogger(LMSAuthDialog.class);
+
@PlugKey("dialog.lmsauth.title")
private static Label LABEL_TITLE;
@Inject private ConnectorService connectorService;
@Inject private ConnectorRepositoryService repositoryService;
+ @Inject private UserSessionService userSessionService;
@ViewFactory private FreemarkerFactory view;
@@ -64,9 +70,9 @@ public LMSAuthDialog() {
@Override
protected SectionRenderable getRenderableContents(RenderContext context) {
final Model model = getModel(context);
-
- final String forwardUrl =
+ String forwardUrl =
new BookmarkAndModify(context, events.getNamedModifier("finishedAuth")).getHref();
+
try {
final String authUrl;
if (authUrlCallable != null) {
@@ -77,8 +83,16 @@ protected SectionRenderable getRenderableContents(RenderContext context) {
throw new RuntimeException("No connector UUID supplied to LMSAuthDialog");
}
final Connector connector = connectorService.getByUuid(connectorUuid);
+ if (connector.getLmsType().equals(BlackboardRESTConnectorConstants.CONNECTOR_TYPE)) {
+ model.setShowNewTabLauncher(true);
+ forwardUrl =
+ new BookmarkAndModify(context, events.getNamedModifier("finishedAuthNewTab"))
+ .getHref();
+ }
+
authUrl = repositoryService.getAuthorisationUrl(connector, forwardUrl, null);
}
+ LOGGER.trace("Setting authUrl to [" + authUrl + "].");
model.setAuthUrl(authUrl);
} catch (Exception e) {
throw Throwables.propagate(e);
@@ -95,19 +109,19 @@ public void treeFinished(String id, SectionTree tree) {
@EventHandlerMethod
public void finishedAuth(SectionInfo info) {
- // final Model model = getModel(info);
- // final String connectorUuid = model.getConnectorUuid();
- // if( connectorUuid == null )
- // {
- // throw new RuntimeException("No connector UUID supplied to LMSAuthDialog");
- // }
- // final Connector connector = connectorService.getByUuid(connectorUuid);
- // repositoryService.finishedAuthorisation(connector);
-
- // Close the dialog
+ LOGGER.trace("Finishing up the auth sequence.");
closeDialog(info, parentCallback, (Object) null);
}
+ @EventHandlerMethod
+ public void finishedAuthNewTab(SectionInfo info) {
+ LOGGER.trace("Finishing up the auth sequence via new tab.");
+ // Dialog is on a different tab, not able to close it.
+ // This is just a workaround until this flow is converted to
+ // the modern UI and we are done with FTL.
+ getModel(info).setShowReceipt(true);
+ }
+
@Override
public String getWidth() {
return "1024px";
@@ -142,6 +156,12 @@ public static class Model extends DialogModel {
private String authUrl;
+ // Default behavior
+ private boolean showNewTabLauncher = false;
+
+ // Default behavior
+ private boolean showReceipt = false;
+
public String getConnectorUuid() {
return connectorUuid;
}
@@ -157,5 +177,21 @@ public String getAuthUrl() {
public void setAuthUrl(String authUrl) {
this.authUrl = authUrl;
}
+
+ public void setShowNewTabLauncher(boolean showNewTabLauncher) {
+ this.showNewTabLauncher = showNewTabLauncher;
+ }
+
+ public boolean isShowNewTabLauncher() {
+ return this.showNewTabLauncher;
+ }
+
+ public void setShowReceipt(boolean showReceipt) {
+ this.showReceipt = showReceipt;
+ }
+
+ public boolean isShowReceipt() {
+ return showReceipt;
+ }
}
}
diff --git a/Source/Plugins/Core/com.equella.core/src/com/tle/web/connectors/export/LMSExportSection.java b/Source/Plugins/Core/com.equella.core/src/com/tle/web/connectors/export/LMSExportSection.java
index 250d8d531e..b7bf308c66 100644
--- a/Source/Plugins/Core/com.equella.core/src/com/tle/web/connectors/export/LMSExportSection.java
+++ b/Source/Plugins/Core/com.equella.core/src/com/tle/web/connectors/export/LMSExportSection.java
@@ -41,6 +41,8 @@
import com.tle.common.i18n.CurrentLocale;
import com.tle.common.settings.standard.CourseDefaultsSettings;
import com.tle.common.usermanagement.user.CurrentUser;
+import com.tle.core.connectors.blackboard.BlackboardRESTConnectorConstants;
+import com.tle.core.connectors.blackboard.service.BlackboardRESTConnectorService;
import com.tle.core.connectors.exception.LmsUserNotFoundException;
import com.tle.core.connectors.service.ConnectorRepositoryService;
import com.tle.core.connectors.service.ConnectorService;
@@ -195,6 +197,7 @@ public class LMSExportSection extends AbstractContentSection selectableAttachments;
@Inject private ConnectorService connectorService;
@Inject private ConnectorRepositoryService repositoryService;
+ @Inject private BlackboardRESTConnectorService blackboardRestConnectorService;
@Inject private ReceiptService receiptService;
@Inject private ViewAttachmentWebService viewAttachmentWebService;
@Inject private ConfigurationService systemConstantsService;
@@ -230,6 +233,10 @@ public class LMSExportSection extends AbstractContentSection attachmentRowDisplays =
viewAttachmentWebService.createViewsForItem(
@@ -454,6 +466,8 @@ public void registered(String id, SectionTree tree) {
.addValidator(locationValidator)
.addValidator(resourceValidator));
+ refreshCourseCacheButton.addClickStatements(events.getNamedHandler("refreshCourseCache"));
+
folderTree.setModel(new CourseTreeModel());
folderTree.setLazyLoad(true);
folderTree.setAllowMultipleOpenBranches(true);
@@ -525,6 +539,19 @@ private boolean checkErrors(SectionInfo info) {
return model.getError() != null;
}
+ @EventHandlerMethod
+ public void refreshCourseCache(SectionInfo info) {
+ final BaseEntityLabel value = connectorsList.getSelectedValue(info);
+
+ if (value != null) {
+ final Connector connector = connectorService.getByUuid(value.getUuid());
+ if (connector != null
+ && connector.getLmsType().equals(BlackboardRESTConnectorConstants.CONNECTOR_TYPE)) {
+ blackboardRestConnectorService.removeCachedCoursesForConnector(connector);
+ }
+ }
+ }
+
@EventHandlerMethod
public void publish(SectionInfo info) {
ensurePriv(info);
@@ -764,6 +791,10 @@ public Button getPublishButton() {
return publishButton;
}
+ public Button getRefreshCourseCacheButton() {
+ return refreshCourseCacheButton;
+ }
+
public Checkbox getShowArchived() {
return showArchived;
}
@@ -786,6 +817,7 @@ public static class LMSExporterModel extends BaseLMSExportModel {
private ConnectorTerminology terms;
private List filteredCourses;
private boolean copyrighted;
+ private boolean courseCaching = false;
public Label getSingleConnectorName() {
return singleConnectorName;
@@ -803,6 +835,14 @@ public void setConnectorsCache(List connectorsCache) {
this.connectorsCache = connectorsCache;
}
+ public boolean isCourseCaching() {
+ return courseCaching;
+ }
+
+ public void setCourseCaching(boolean courseCaching) {
+ this.courseCaching = courseCaching;
+ }
+
public boolean isAuthRequired() {
return authRequired;
}