From a04be0442ba1ecf2e7058919436f1d93cdb22aba Mon Sep 17 00:00:00 2001 From: Chris Beach Date: Tue, 23 Jun 2020 15:50:16 -0500 Subject: [PATCH] Feature/821 bb rest 3lo try2 (#1781) * #821 MVP code for 3LO auth * Three legged authentication for Blackboard. (Incomplete) * #819 - Courses now cache Co-authored-by: Diego del Blanco --- .../Core/com.equella.core/plugin-jpf.xml | 4 + .../lang/i18n-resource-centre.properties | 4 + .../view/blackboardrestconnector.ftl | 21 +- .../resources/view/dialog/lmsauth.ftl | 23 +- .../resources/view/lmsexporter.ftl | 11 +- .../tle/core/auditlog/AuditLogService.java | 8 + .../auditlog/impl/AuditLogServiceImpl.java | 18 + .../BlackboardRESTConnectorConstants.java | 22 +- .../blackboard/BlackboardRestAppContext.java | 80 +++ .../blackboard/beans/Availability.java | 3 +- .../connectors/blackboard/beans/Course.java | 7 +- .../blackboard/beans/CourseByUser.java | 34 ++ .../blackboard/beans/CoursesByUser.java | 24 + .../connectors/blackboard/beans/Token.java | 22 + .../BlackboardRESTConnectorService.java | 40 +- .../BlackboardRESTConnectorServiceImpl.java | 505 ++++++++++++------ .../editor/BlackboardRESTConnectorEditor.java | 67 +-- .../BlackboardRestOauthSignonServlet.java | 167 ++++++ .../web/connectors/dialog/LMSAuthDialog.java | 60 ++- .../connectors/export/LMSExportSection.java | 40 ++ 20 files changed, 896 insertions(+), 264 deletions(-) create mode 100644 Source/Plugins/Core/com.equella.core/src/com/tle/core/connectors/blackboard/BlackboardRestAppContext.java create mode 100644 Source/Plugins/Core/com.equella.core/src/com/tle/core/connectors/blackboard/beans/CourseByUser.java create mode 100644 Source/Plugins/Core/com.equella.core/src/com/tle/core/connectors/blackboard/beans/CoursesByUser.java create mode 100644 Source/Plugins/Core/com.equella.core/src/com/tle/web/connectors/blackboard/servlet/BlackboardRestOauthSignonServlet.java diff --git a/Source/Plugins/Core/com.equella.core/plugin-jpf.xml b/Source/Plugins/Core/com.equella.core/plugin-jpf.xml index e45961473f..14ecc6257d 100644 --- a/Source/Plugins/Core/com.equella.core/plugin-jpf.xml +++ b/Source/Plugins/Core/com.equella.core/plugin-jpf.xml @@ -4271,6 +4271,10 @@ + + + + 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')> -
- - -<@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)} - - - - <@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> + + + + 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 /> +
+ +
<@render s.publishButton />
@@ -96,4 +101,4 @@ - \ 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; }