diff --git a/src/main/java/ch/ethz/seb/sebserver/WebSecurityConfig.java b/src/main/java/ch/ethz/seb/sebserver/WebSecurityConfig.java index 539e7128..49b2fbe5 100644 --- a/src/main/java/ch/ethz/seb/sebserver/WebSecurityConfig.java +++ b/src/main/java/ch/ethz/seb/sebserver/WebSecurityConfig.java @@ -8,18 +8,32 @@ package ch.ethz.seb.sebserver; +import java.io.FileNotFoundException; import java.io.IOException; +import java.net.HttpURLConnection; +import java.security.KeyManagementException; +import java.security.KeyStoreException; +import java.security.NoSuchAlgorithmException; +import java.security.cert.CertificateException; +import javax.net.ssl.SSLContext; import javax.servlet.ServletException; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; +import org.apache.http.client.HttpClient; +import org.apache.http.impl.client.HttpClients; +import org.apache.http.ssl.SSLContextBuilder; import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.web.servlet.error.ErrorController; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.core.annotation.Order; +import org.springframework.core.env.Environment; import org.springframework.http.HttpStatus; +import org.springframework.http.client.ClientHttpRequestFactory; +import org.springframework.http.client.HttpComponentsClientHttpRequestFactory; +import org.springframework.http.client.SimpleClientHttpRequestFactory; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.builders.WebSecurity; import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter; @@ -28,10 +42,15 @@ import org.springframework.security.core.AuthenticationException; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.web.AuthenticationEntryPoint; +import org.springframework.util.ResourceUtils; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +import ch.ethz.seb.sebserver.gbl.profile.DevGuiProfile; +import ch.ethz.seb.sebserver.gbl.profile.DevWebServiceProfile; import ch.ethz.seb.sebserver.gbl.profile.GuiProfile; +import ch.ethz.seb.sebserver.gbl.profile.ProdGuiProfile; +import ch.ethz.seb.sebserver.gbl.profile.ProdWebServiceProfile; import ch.ethz.seb.sebserver.gbl.profile.WebServiceProfile; /** This is the overall seb-server Spring web-configuration that is loaded for all profiles. @@ -115,4 +134,64 @@ public class WebSecurityConfig extends WebSecurityConfigurerAdapter implements E return "/error"; } + /** A ClientHttpRequestFactory for development profile with no TSL SSL protocol and + * not following redirects on redirect responses. + * + * @return ClientHttpRequestFactory bean for development profiles */ + @Bean + @DevGuiProfile + @DevWebServiceProfile + public ClientHttpRequestFactory clientHttpRequestFactory() { + // TODO set connection and read timeout!? configurable!? + return new SimpleClientHttpRequestFactory() { + + @Override + protected void prepareConnection(final HttpURLConnection connection, final String httpMethod) + throws IOException { + super.prepareConnection(connection, httpMethod); + connection.setInstanceFollowRedirects(false); + } + }; + } + + /** A ClientHttpRequestFactory used in production with TSL SSL configuration. + * + * NOTE: + * environment property: sebserver.gui.truststore.pwd is expected to have the correct truststore password set + * environment property: sebserver.gui.truststore.type is expected to set to the correct type of truststore + * truststore.jks is expected to be on the classpath containing all trusted certificates for request + * to SSL secured SEB Server webservice + * + * @return ClientHttpRequestFactory with TLS / SSL configuration + * @throws IOException + * @throws FileNotFoundException + * @throws CertificateException + * @throws KeyStoreException + * @throws NoSuchAlgorithmException + * @throws KeyManagementException */ + @Bean + @ProdGuiProfile + @ProdWebServiceProfile + public ClientHttpRequestFactory clientHttpRequestFactoryTLS(final Environment env) throws KeyManagementException, + NoSuchAlgorithmException, KeyStoreException, CertificateException, FileNotFoundException, IOException { + + final char[] password = env + .getProperty("sebserver.gui.truststore.pwd") + .toCharArray(); + + final SSLContext sslContext = SSLContextBuilder + .create() + .loadTrustMaterial(ResourceUtils.getFile( + "classpath:truststore.jks"), + password) + .build(); + + final HttpClient client = HttpClients.custom() + .setSSLContext(sslContext) + .build(); + + // TODO set connection and read timeout!? configurable!? + return new HttpComponentsClientHttpRequestFactory(client); + } + } diff --git a/src/main/java/ch/ethz/seb/sebserver/gbl/model/institution/LmsSetupTestResult.java b/src/main/java/ch/ethz/seb/sebserver/gbl/model/institution/LmsSetupTestResult.java index 741fa456..3e5f24d4 100644 --- a/src/main/java/ch/ethz/seb/sebserver/gbl/model/institution/LmsSetupTestResult.java +++ b/src/main/java/ch/ethz/seb/sebserver/gbl/model/institution/LmsSetupTestResult.java @@ -8,24 +8,93 @@ package ch.ethz.seb.sebserver.gbl.model.institution; +import java.util.Arrays; +import java.util.Collection; +import java.util.Set; + import javax.validation.constraints.NotNull; import com.fasterxml.jackson.annotation.JsonProperty; -public class LmsSetupTestResult { +import ch.ethz.seb.sebserver.gbl.util.Utils; - public static final String ATTR_OK = "ok"; +public final class LmsSetupTestResult { - @JsonProperty(ATTR_OK) + public static final String ATTR_OK_STATUS = "okStatus"; + public static final String ATTR_MISSING_ATTRIBUTE = "missingLMSSetupAttribute"; + public static final String ATTR_ERROR_TOKEN_REQUEST = "tokenRequestError"; + public static final String ATTR_ERROR_QUIZ_REQUEST = "quizRequestError"; + + @JsonProperty(ATTR_OK_STATUS) @NotNull - public final Boolean ok; + public final Boolean okStatus; - // TODO + @JsonProperty(ATTR_MISSING_ATTRIBUTE) + public final Set missingLMSSetupAttribute; + + @JsonProperty(ATTR_MISSING_ATTRIBUTE) + public final String tokenRequestError; + + @JsonProperty(ATTR_MISSING_ATTRIBUTE) + public final String quizRequestError; public LmsSetupTestResult( - @JsonProperty(value = ATTR_OK, required = true) final Boolean ok) { + @JsonProperty(value = ATTR_OK_STATUS, required = true) final Boolean ok, + @JsonProperty(ATTR_MISSING_ATTRIBUTE) final Collection missingLMSSetupAttribute, + @JsonProperty(ATTR_MISSING_ATTRIBUTE) final String tokenRequestError, + @JsonProperty(ATTR_MISSING_ATTRIBUTE) final String quizRequestError) { - this.ok = ok; + this.okStatus = ok; + // TODO + this.missingLMSSetupAttribute = Utils.immutableSetOf(missingLMSSetupAttribute); + this.tokenRequestError = tokenRequestError; + this.quizRequestError = quizRequestError; + } + + public Boolean getOkStatus() { + return this.okStatus; + } + + public Set getMissingLMSSetupAttribute() { + return this.missingLMSSetupAttribute; + } + + public String getTokenRequestError() { + return this.tokenRequestError; + } + + public String getQuizRequestError() { + return this.quizRequestError; + } + + @Override + public String toString() { + return "LmsSetupTestResult [okStatus=" + this.okStatus + ", missingLMSSetupAttribute=" + + this.missingLMSSetupAttribute + + ", tokenRequestError=" + this.tokenRequestError + ", quizRequestError=" + this.quizRequestError + "]"; + } + + public static final LmsSetupTestResult ofOkay() { + return new LmsSetupTestResult(true, null, null, null); + } + + public static final LmsSetupTestResult ofMissingAttributes(final Collection attrs) { + return new LmsSetupTestResult(false, attrs, null, null); + } + + public static final LmsSetupTestResult ofMissingAttributes(final String... attrs) { + if (attrs == null) { + return new LmsSetupTestResult(false, null, null, null); + } + return new LmsSetupTestResult(false, Arrays.asList(attrs), null, null); + } + + public static final LmsSetupTestResult ofTokenRequestError(final String message) { + return new LmsSetupTestResult(false, null, message, null); + } + + public static final LmsSetupTestResult ofQuizRequestError(final String message) { + return new LmsSetupTestResult(false, null, null, message); } } diff --git a/src/main/java/ch/ethz/seb/sebserver/gbl/profile/ProdGuiProfile.java b/src/main/java/ch/ethz/seb/sebserver/gbl/profile/ProdGuiProfile.java index bfe6f2b7..8fe0b422 100644 --- a/src/main/java/ch/ethz/seb/sebserver/gbl/profile/ProdGuiProfile.java +++ b/src/main/java/ch/ethz/seb/sebserver/gbl/profile/ProdGuiProfile.java @@ -21,6 +21,6 @@ import org.springframework.context.annotation.Profile; * and only for production and/or testing */ @Target({ ElementType.TYPE, ElementType.METHOD }) @Retention(RetentionPolicy.RUNTIME) -@Profile({ "prod-gui", "test" }) +@Profile({ "prod-gui" }) public @interface ProdGuiProfile { } diff --git a/src/main/java/ch/ethz/seb/sebserver/gbl/profile/ProdWebServiceProfile.java b/src/main/java/ch/ethz/seb/sebserver/gbl/profile/ProdWebServiceProfile.java index 23db34ff..5b571b5a 100644 --- a/src/main/java/ch/ethz/seb/sebserver/gbl/profile/ProdWebServiceProfile.java +++ b/src/main/java/ch/ethz/seb/sebserver/gbl/profile/ProdWebServiceProfile.java @@ -21,6 +21,6 @@ import org.springframework.context.annotation.Profile; * and only for production and/or testing */ @Target({ ElementType.TYPE, ElementType.METHOD }) @Retention(RetentionPolicy.RUNTIME) -@Profile({ "prod-ws", "test" }) +@Profile({ "prod-ws" }) public @interface ProdWebServiceProfile { } diff --git a/src/main/java/ch/ethz/seb/sebserver/gui/service/remote/webservice/WebserviceConnectionConfig.java b/src/main/java/ch/ethz/seb/sebserver/gui/service/remote/webservice/WebserviceConnectionConfig.java deleted file mode 100644 index cb364aee..00000000 --- a/src/main/java/ch/ethz/seb/sebserver/gui/service/remote/webservice/WebserviceConnectionConfig.java +++ /dev/null @@ -1,96 +0,0 @@ -/* - * Copyright (c) 2019 ETH Zürich, Educational Development and Technology (LET) - * - * This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at http://mozilla.org/MPL/2.0/. - */ - -package ch.ethz.seb.sebserver.gui.service.remote.webservice; - -import java.io.FileNotFoundException; -import java.io.IOException; -import java.net.HttpURLConnection; -import java.security.KeyManagementException; -import java.security.KeyStoreException; -import java.security.NoSuchAlgorithmException; -import java.security.cert.CertificateException; - -import javax.net.ssl.SSLContext; - -import org.apache.http.client.HttpClient; -import org.apache.http.impl.client.HttpClients; -import org.apache.http.ssl.SSLContextBuilder; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.core.env.Environment; -import org.springframework.http.client.ClientHttpRequestFactory; -import org.springframework.http.client.HttpComponentsClientHttpRequestFactory; -import org.springframework.http.client.SimpleClientHttpRequestFactory; -import org.springframework.util.ResourceUtils; - -import ch.ethz.seb.sebserver.gbl.profile.DevGuiProfile; -import ch.ethz.seb.sebserver.gbl.profile.GuiProfile; -import ch.ethz.seb.sebserver.gbl.profile.ProdGuiProfile; - -@Configuration -@GuiProfile -public class WebserviceConnectionConfig { - - /** A ClientHttpRequestFactory for development profile with no TSL SSL protocol and - * not following redirects on redirect responses. - * - * @return ClientHttpRequestFactory bean for development profiles */ - @Bean - @DevGuiProfile - public ClientHttpRequestFactory clientHttpRequestFactory() { - return new SimpleClientHttpRequestFactory() { - - @Override - protected void prepareConnection(final HttpURLConnection connection, final String httpMethod) - throws IOException { - super.prepareConnection(connection, httpMethod); - connection.setInstanceFollowRedirects(false); - } - }; - } - - /** A ClientHttpRequestFactory used in production with TSL SSL configuration. - * - * NOTE: - * environment property: sebserver.gui.truststore.pwd is expected to have the correct truststore password set - * environment property: sebserver.gui.truststore.type is expected to set to the correct type of truststore - * truststore.jks is expected to be on the classpath containing all trusted certificates for request - * to SSL secured SEB Server webservice - * - * @return ClientHttpRequestFactory with TLS / SSL configuration - * @throws IOException - * @throws FileNotFoundException - * @throws CertificateException - * @throws KeyStoreException - * @throws NoSuchAlgorithmException - * @throws KeyManagementException */ - @Bean - @ProdGuiProfile - public ClientHttpRequestFactory clientHttpRequestFactoryTLS(final Environment env) throws KeyManagementException, - NoSuchAlgorithmException, KeyStoreException, CertificateException, FileNotFoundException, IOException { - - final char[] password = env - .getProperty("sebserver.gui.truststore.pwd") - .toCharArray(); - - final SSLContext sslContext = SSLContextBuilder - .create() - .loadTrustMaterial(ResourceUtils.getFile( - "classpath:truststore.jks"), - password) - .build(); - - final HttpClient client = HttpClients.custom() - .setSSLContext(sslContext) - .build(); - - return new HttpComponentsClientHttpRequestFactory(client); - } - -} diff --git a/src/main/java/ch/ethz/seb/sebserver/gui/service/remote/webservice/auth/OAuth2AuthorizationContextHolder.java b/src/main/java/ch/ethz/seb/sebserver/gui/service/remote/webservice/auth/OAuth2AuthorizationContextHolder.java index 174bf3aa..ad3f865e 100644 --- a/src/main/java/ch/ethz/seb/sebserver/gui/service/remote/webservice/auth/OAuth2AuthorizationContextHolder.java +++ b/src/main/java/ch/ethz/seb/sebserver/gui/service/remote/webservice/auth/OAuth2AuthorizationContextHolder.java @@ -61,8 +61,8 @@ public class OAuth2AuthorizationContextHolder implements AuthorizationContextHol @Autowired public OAuth2AuthorizationContextHolder( - @Value("${sebserver.gui.webservice.clientId}") final String guiClientId, - @Value("${sebserver.gui.webservice.clientSecret}") final String guiClientSecret, + @Value("${sebserver.webservice.api.admin.clientId}") final String guiClientId, + @Value("${sebserver.webservice.api.admin.clientId}") final String guiClientSecret, final WebserviceURIService webserviceURIService, final ClientHttpRequestFactory clientHttpRequestFactory) { diff --git a/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/InternalEncryptionService.java b/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/InternalEncryptionService.java new file mode 100644 index 00000000..a23396db --- /dev/null +++ b/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/InternalEncryptionService.java @@ -0,0 +1,97 @@ +/* + * Copyright (c) 2019 ETH Zürich, Educational Development and Technology (LET) + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +package ch.ethz.seb.sebserver.webservice.servicelayer; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.context.annotation.Lazy; +import org.springframework.core.env.Environment; +import org.springframework.security.crypto.encrypt.Encryptors; +import org.springframework.stereotype.Service; + +import ch.ethz.seb.sebserver.gbl.profile.WebServiceProfile; + +@Lazy +@Service +@WebServiceProfile +public class InternalEncryptionService { + + private static final Logger log = LoggerFactory.getLogger(InternalEncryptionService.class); + + private static final String NO_SALT = "NO_SALT"; + + private final Environment environment; + + protected InternalEncryptionService(final Environment environment) { + this.environment = environment; + } + + public String encrypt(final String text) { + try { + return Encryptors.text( + this.environment.getRequiredProperty("sebserver.webservice.internalSecret"), + NO_SALT).encrypt(text); + } catch (final Exception e) { + log.error("Failed to encrypt text: ", e); + return null; + } + } + + public String decrypt(final String text) { + try { + return Encryptors.text( + this.environment.getRequiredProperty("sebserver.webservice.internalSecret"), + NO_SALT).decrypt(text); + } catch (final Exception e) { + log.error("Failed to decrypt text: ", e); + return null; + } + } + + public String encrypt(final String text, final CharSequence salt) { + try { + return Encryptors.text( + this.environment.getRequiredProperty("sebserver.webservice.internalSecret"), + salt).encrypt(text); + } catch (final Exception e) { + log.error("Failed to encrypt text: ", e); + return null; + } + } + + public String decrypt(final String text, final CharSequence salt) { + try { + return Encryptors.text( + this.environment.getRequiredProperty("sebserver.webservice.internalSecret"), + salt).decrypt(text); + } catch (final Exception e) { + log.error("Failed to decrypt text: ", e); + return null; + } + } + + public String encrypt(final String text, final CharSequence secret, final CharSequence salt) { + try { + return Encryptors.text(secret, salt).encrypt(text); + } catch (final Exception e) { + log.error("Failed to encrypt text: ", e); + return null; + } + } + + public String decrypt(final String text, final CharSequence secret, final CharSequence salt) { + try { + return Encryptors.text(secret, salt).decrypt(text); + } catch (final Exception e) { + log.error("Failed to decrypt text: ", e); + return null; + } + } + +} diff --git a/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/dao/impl/LmsSetupDAOImpl.java b/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/dao/impl/LmsSetupDAOImpl.java index 64bba81f..187e4df7 100644 --- a/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/dao/impl/LmsSetupDAOImpl.java +++ b/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/dao/impl/LmsSetupDAOImpl.java @@ -18,12 +18,16 @@ import java.util.function.Predicate; import java.util.stream.Collectors; import org.apache.commons.lang3.BooleanUtils; +import org.apache.commons.lang3.StringUtils; import org.mybatis.dynamic.sql.select.MyBatis3SelectModelAdapter; import org.mybatis.dynamic.sql.select.QueryExpressionDSL; +import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.context.annotation.Lazy; +import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; +import ch.ethz.seb.sebserver.WebSecurityConfig; import ch.ethz.seb.sebserver.gbl.model.EntityKey; import ch.ethz.seb.sebserver.gbl.model.EntityType; import ch.ethz.seb.sebserver.gbl.model.institution.LmsSetup; @@ -32,6 +36,7 @@ import ch.ethz.seb.sebserver.gbl.util.Result; import ch.ethz.seb.sebserver.webservice.datalayer.batis.mapper.LmsSetupRecordDynamicSqlSupport; import ch.ethz.seb.sebserver.webservice.datalayer.batis.mapper.LmsSetupRecordMapper; import ch.ethz.seb.sebserver.webservice.datalayer.batis.model.LmsSetupRecord; +import ch.ethz.seb.sebserver.webservice.servicelayer.InternalEncryptionService; import ch.ethz.seb.sebserver.webservice.servicelayer.bulkaction.BulkAction; import ch.ethz.seb.sebserver.webservice.servicelayer.dao.DAOLoggingSupport; import ch.ethz.seb.sebserver.webservice.servicelayer.dao.FilterMap; @@ -44,9 +49,17 @@ import ch.ethz.seb.sebserver.webservice.servicelayer.dao.TransactionHandler; public class LmsSetupDAOImpl implements LmsSetupDAO { private final LmsSetupRecordMapper lmsSetupRecordMapper; + private final InternalEncryptionService internalEncryptionService; + private final PasswordEncoder clientPasswordEncoder; + + protected LmsSetupDAOImpl( + final LmsSetupRecordMapper lmsSetupRecordMapper, + final InternalEncryptionService internalEncryptionService, + @Qualifier(WebSecurityConfig.CLIENT_PASSWORD_ENCODER_BEAN_NAME) final PasswordEncoder clientPasswordEncoder) { - public LmsSetupDAOImpl(final LmsSetupRecordMapper lmsSetupRecordMapper) { this.lmsSetupRecordMapper = lmsSetupRecordMapper; + this.internalEncryptionService = internalEncryptionService; + this.clientPasswordEncoder = clientPasswordEncoder; } @Override @@ -132,10 +145,14 @@ public class LmsSetupDAOImpl implements LmsSetupDAO { (lmsSetup.lmsType != null) ? lmsSetup.lmsType.name() : null, lmsSetup.lmsApiUrl, lmsSetup.lmsAuthName, - lmsSetup.lmsAuthSecret, + (StringUtils.isNotBlank(lmsSetup.lmsAuthSecret)) + ? this.internalEncryptionService.encrypt(lmsSetup.lmsAuthSecret) + : null, lmsSetup.lmsRestApiToken, lmsSetup.sebAuthName, - lmsSetup.sebAuthSecret, + (StringUtils.isNotBlank(lmsSetup.sebAuthSecret)) + ? this.clientPasswordEncoder.encode(lmsSetup.sebAuthSecret) + : null, null); this.lmsSetupRecordMapper.updateByPrimaryKeySelective(newRecord); @@ -277,4 +294,29 @@ public class LmsSetupDAOImpl implements LmsSetupDAO { BooleanUtils.toBooleanObject(record.getActive()))); } +// private LmsSetup handlePasswortReset(LmsSetup lmsSetup) { +// String lmsPWDEncrypted = null; +// String sebPWDEncrypted = null; +// if (StringUtils.isNotBlank(lmsSetup.lmsAuthName) && StringUtils.isNotBlank(lmsSetup.lmsAuthSecret)) { +// +// } +// +// if (StringUtils.isNotBlank(lmsSetup.sebAuthName) && StringUtils.isNotBlank(lmsSetup.sebAuthSecret)) { +// +// } +// +// return new LmsSetup( +// lmsSetup.id, +// lmsSetup.institutionId, +// lmsSetup.name, +// lmsSetup.lmsType, +// lmsSetup.lmsAuthName, +// lmsPWDEncrypted, +// lmsSetup.lmsApiUrl, +// lmsSetup.lmsRestApiToken, +// lmsSetup.sebAuthName, +// sebPWDEncrypted, +// lmsSetup.active); +// } + } diff --git a/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/lms/LmsAPITemplate.java b/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/lms/LmsAPITemplate.java index d10109a5..7d821222 100644 --- a/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/lms/LmsAPITemplate.java +++ b/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/lms/LmsAPITemplate.java @@ -24,7 +24,7 @@ public interface LmsAPITemplate { LmsSetupTestResult testLmsSetup(); - Page getQuizzes( + Result> getQuizzes( String name, Long from, String sort, diff --git a/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/lms/LmsBindingConfig.java b/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/lms/LmsBindingConfig.java new file mode 100644 index 00000000..4aa58e54 --- /dev/null +++ b/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/lms/LmsBindingConfig.java @@ -0,0 +1,19 @@ +/* + * Copyright (c) 2019 ETH Zürich, Educational Development and Technology (LET) + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +package ch.ethz.seb.sebserver.webservice.servicelayer.lms; + +import org.springframework.context.annotation.Configuration; + +import ch.ethz.seb.sebserver.gbl.profile.WebServiceProfile; + +@Configuration +@WebServiceProfile +public class LmsBindingConfig { + +} diff --git a/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/lms/impl/LmsAPIServiceImpl.java b/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/lms/impl/LmsAPIServiceImpl.java index 11e20088..5052ec0c 100644 --- a/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/lms/impl/LmsAPIServiceImpl.java +++ b/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/lms/impl/LmsAPIServiceImpl.java @@ -10,11 +10,16 @@ package ch.ethz.seb.sebserver.webservice.servicelayer.lms.impl; import java.io.InputStream; +import org.apache.commons.lang3.StringUtils; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.client.ClientHttpRequestFactory; import org.springframework.stereotype.Service; +import ch.ethz.seb.sebserver.gbl.Constants; import ch.ethz.seb.sebserver.gbl.model.institution.LmsSetup; import ch.ethz.seb.sebserver.gbl.profile.WebServiceProfile; import ch.ethz.seb.sebserver.gbl.util.Result; +import ch.ethz.seb.sebserver.webservice.servicelayer.InternalEncryptionService; import ch.ethz.seb.sebserver.webservice.servicelayer.dao.LmsSetupDAO; import ch.ethz.seb.sebserver.webservice.servicelayer.lms.LmsAPIService; import ch.ethz.seb.sebserver.webservice.servicelayer.lms.LmsAPITemplate; @@ -24,9 +29,23 @@ import ch.ethz.seb.sebserver.webservice.servicelayer.lms.LmsAPITemplate; public class LmsAPIServiceImpl implements LmsAPIService { private final LmsSetupDAO lmsSetupDAO; + private final InternalEncryptionService internalEncryptionService; + private final ClientHttpRequestFactory clientHttpRequestFactory; + private final String[] openEdxAlternativeTokenRequestPaths; + + public LmsAPIServiceImpl( + final LmsSetupDAO lmsSetupDAO, + final InternalEncryptionService internalEncryptionService, + final ClientHttpRequestFactory clientHttpRequestFactory, + @Value("${sebserver.lms.openedix.api.token.request.paths}") final String alternativeTokenRequestPaths) { - public LmsAPIServiceImpl(final LmsSetupDAO lmsSetupDAO) { this.lmsSetupDAO = lmsSetupDAO; + this.internalEncryptionService = internalEncryptionService; + this.clientHttpRequestFactory = clientHttpRequestFactory; + + this.openEdxAlternativeTokenRequestPaths = (alternativeTokenRequestPaths != null) + ? StringUtils.split(alternativeTokenRequestPaths, Constants.LIST_SEPARATOR) + : null; } @Override @@ -41,10 +60,17 @@ public class LmsAPIServiceImpl implements LmsAPIService { switch (lmsSetup.lmsType) { case MOCKUP: return Result.of(new MockupLmsAPITemplate(lmsSetup)); + case OPEN_EDX: + return Result.of(new OpenEdxLmsAPITemplate( + lmsSetup, + this.internalEncryptionService, + this.clientHttpRequestFactory, + this.openEdxAlternativeTokenRequestPaths)); default: return Result.ofError( new UnsupportedOperationException("No support for LMS Type: " + lmsSetup.lmsType)); } + } @Override diff --git a/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/lms/impl/MockupLmsAPITemplate.java b/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/lms/impl/MockupLmsAPITemplate.java index 6bace421..c1c36ad7 100644 --- a/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/lms/impl/MockupLmsAPITemplate.java +++ b/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/lms/impl/MockupLmsAPITemplate.java @@ -16,6 +16,7 @@ import java.util.List; import java.util.Set; import java.util.stream.Collectors; +import ch.ethz.seb.sebserver.gbl.model.Domain.LMS_SETUP; import ch.ethz.seb.sebserver.gbl.model.Page; import ch.ethz.seb.sebserver.gbl.model.exam.QuizData; import ch.ethz.seb.sebserver.gbl.model.institution.LmsSetup; @@ -26,13 +27,13 @@ import ch.ethz.seb.sebserver.gbl.util.Result; import ch.ethz.seb.sebserver.webservice.servicelayer.PaginationService.SortOrder; import ch.ethz.seb.sebserver.webservice.servicelayer.lms.LmsAPITemplate; -public class MockupLmsAPITemplate implements LmsAPITemplate { +final class MockupLmsAPITemplate implements LmsAPITemplate { private final LmsSetup setup; private final Collection mockups; - public MockupLmsAPITemplate(final LmsSetup setup) { + MockupLmsAPITemplate(final LmsSetup setup) { if (!setup.isActive() || setup.lmsType != LmsType.MOCKUP) { throw new IllegalArgumentException(); } @@ -69,13 +70,19 @@ public class MockupLmsAPITemplate implements LmsAPITemplate { @Override public LmsSetupTestResult testLmsSetup() { + if (this.setup.lmsType != LmsType.MOCKUP) { + return LmsSetupTestResult.ofMissingAttributes(LMS_SETUP.ATTR_LMS_TYPE); + } if (this.setup.lmsApiUrl.equals("mockup") && this.setup.lmsAuthName.equals("mockup") && this.setup.lmsAuthSecret.equals("mockup")) { - return new LmsSetupTestResult(true); + return LmsSetupTestResult.ofOkay(); } else { - return new LmsSetupTestResult(false); + return LmsSetupTestResult.ofMissingAttributes( + LMS_SETUP.ATTR_LMS_URL, + LMS_SETUP.ATTR_LMS_CLIENTNAME, + LMS_SETUP.ATTR_LMS_CLIENTSECRET); } } @@ -106,29 +113,31 @@ public class MockupLmsAPITemplate implements LmsAPITemplate { } @Override - public Page getQuizzes( + public Result> getQuizzes( final String name, final Long from, final String sort, final int pageNumber, final int pageSize) { - final int startIndex = pageNumber * pageSize; - final int endIndex = startIndex + pageSize; - int index = 0; - final Collection quizzes = getQuizzes(name, from, sort); - final int numberOfPages = quizzes.size() / pageSize; - final Iterator iterator = quizzes.iterator(); - final List pageContent = new ArrayList<>(); - while (iterator.hasNext() && index < endIndex) { - final QuizData next = iterator.next(); - if (index >= startIndex) { - pageContent.add(next); + return Result.tryCatch(() -> { + final int startIndex = pageNumber * pageSize; + final int endIndex = startIndex + pageSize; + int index = 0; + final Collection quizzes = getQuizzes(name, from, sort); + final int numberOfPages = quizzes.size() / pageSize; + final Iterator iterator = quizzes.iterator(); + final List pageContent = new ArrayList<>(); + while (iterator.hasNext() && index < endIndex) { + final QuizData next = iterator.next(); + if (index >= startIndex) { + pageContent.add(next); + } + index++; } - index++; - } - return new Page<>(numberOfPages, pageNumber, sort, pageContent); + return new Page<>(numberOfPages, pageNumber, sort, pageContent); + }); } @Override diff --git a/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/lms/impl/OpenEdxLmsAPITemplate.java b/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/lms/impl/OpenEdxLmsAPITemplate.java new file mode 100644 index 00000000..2d16ce82 --- /dev/null +++ b/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/lms/impl/OpenEdxLmsAPITemplate.java @@ -0,0 +1,326 @@ +/* + * Copyright (c) 2019 ETH Zürich, Educational Development and Technology (LET) + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +package ch.ethz.seb.sebserver.webservice.servicelayer.lms.impl; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.HashSet; +import java.util.Iterator; +import java.util.List; +import java.util.Set; + +import org.apache.commons.lang3.StringUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.ResponseEntity; +import org.springframework.http.client.ClientHttpRequestFactory; +import org.springframework.security.crypto.encrypt.Encryptors; +import org.springframework.security.crypto.encrypt.TextEncryptor; +import org.springframework.security.oauth2.client.OAuth2RestTemplate; +import org.springframework.security.oauth2.client.token.grant.client.ClientCredentialsResourceDetails; +import org.springframework.security.oauth2.common.DefaultOAuth2AccessToken; +import org.springframework.security.oauth2.common.OAuth2AccessToken; + +import ch.ethz.seb.sebserver.gbl.model.Domain.LMS_SETUP; +import ch.ethz.seb.sebserver.gbl.model.Page; +import ch.ethz.seb.sebserver.gbl.model.exam.QuizData; +import ch.ethz.seb.sebserver.gbl.model.institution.LmsSetup; +import ch.ethz.seb.sebserver.gbl.model.institution.LmsSetup.LmsType; +import ch.ethz.seb.sebserver.gbl.model.institution.LmsSetupTestResult; +import ch.ethz.seb.sebserver.gbl.model.user.ExamineeAccountDetails; +import ch.ethz.seb.sebserver.gbl.util.Result; +import ch.ethz.seb.sebserver.webservice.servicelayer.InternalEncryptionService; +import ch.ethz.seb.sebserver.webservice.servicelayer.lms.LmsAPITemplate; + +final class OpenEdxLmsAPITemplate implements LmsAPITemplate { + + private static final Logger log = LoggerFactory.getLogger(OpenEdxLmsAPITemplate.class); + + private static final String OPEN_EDX_DEFAULT_TOKEN_REQUEST_PATH = "/oauth2/access_token"; + private static final String OPEN_EDX_DEFAULT_COURSE_ENDPOINT = "/api/courses/v1/courses/"; + private static final String OPEN_EDX_DEFAULT_COURSE_START_URL_PREFIX = "/courses/"; + + private final LmsSetup lmsSetup; + private final ClientHttpRequestFactory clientHttpRequestFactory; + private final InternalEncryptionService internalEncryptionService; + private final Set knownTokenAccessPaths; + + private OAuth2RestTemplate restTemplate = null; + + OpenEdxLmsAPITemplate( + final LmsSetup lmsSetup, + final InternalEncryptionService internalEncryptionService, + final ClientHttpRequestFactory clientHttpRequestFactory, + final String[] alternativeTokenRequestPaths) { + + this.lmsSetup = lmsSetup; + this.clientHttpRequestFactory = clientHttpRequestFactory; + this.internalEncryptionService = internalEncryptionService; + + this.knownTokenAccessPaths = new HashSet<>(); + this.knownTokenAccessPaths.add(OPEN_EDX_DEFAULT_TOKEN_REQUEST_PATH); + if (alternativeTokenRequestPaths != null) { + this.knownTokenAccessPaths.addAll(Arrays.asList(alternativeTokenRequestPaths)); + } + } + + @Override + public LmsSetup lmsSetup() { + return this.lmsSetup; + } + + @Override + public LmsSetupTestResult testLmsSetup() { + + log.info("Test Lms Binding for OpenEdX and LmsSetup: {}", this.lmsSetup); + + // validation of LmsSetup + if (this.lmsSetup.lmsType != LmsType.MOCKUP) { + return LmsSetupTestResult.ofMissingAttributes(LMS_SETUP.ATTR_LMS_TYPE); + } + final List missingAttrs = new ArrayList<>(); + if (StringUtils.isBlank(this.lmsSetup.lmsApiUrl)) { + missingAttrs.add(LMS_SETUP.ATTR_LMS_TYPE); + } + if (StringUtils.isBlank(this.lmsSetup.getLmsAuthName())) { + missingAttrs.add(LMS_SETUP.ATTR_LMS_CLIENTNAME); + } + if (StringUtils.isBlank(this.lmsSetup.getLmsAuthSecret())) { + missingAttrs.add(LMS_SETUP.ATTR_LMS_CLIENTSECRET); + } + + if (!missingAttrs.isEmpty()) { + return LmsSetupTestResult.ofMissingAttributes(missingAttrs); + } + + // request OAuth2 access token on OpenEdx API + initRestTemplateAndRequestAccessToken(); + if (this.restTemplate == null) { + return LmsSetupTestResult.ofTokenRequestError( + "Failed to gain access token form OpenEdX Rest API: tried token endpoints: " + + this.knownTokenAccessPaths); + } + + // query quizzes TODO!? + + return LmsSetupTestResult.ofOkay(); + } + + @Override + public Result> getQuizzes( + final String name, + final Long from, + final String sort, + final int pageNumber, + final int pageSize) { + + return Result.tryCatch(() -> { + initRestTemplateAndRequestAccessToken(); + + // TODO sort and pagination + final HttpHeaders httpHeaders = new HttpHeaders(); + + final ResponseEntity response = this.restTemplate.exchange( + this.lmsSetup.lmsApiUrl + OPEN_EDX_DEFAULT_COURSE_ENDPOINT, + HttpMethod.GET, + new HttpEntity<>(httpHeaders), + EdXPage.class); + final EdXPage edxpage = response.getBody(); + + final List content = edxpage.results + .stream() + .reduce( + new ArrayList(), + (list, courseData) -> { + list.add(quizDataOf(courseData)); + return list; + }, + (list1, list2) -> { + list1.addAll(list2); + return list1; + }); + + return new Page<>(edxpage.num_pages, pageNumber, sort, content); + }); + } + + @Override + public Collection> getQuizzes(final Set ids) { + // TODO Auto-generated method stub + return null; + } + + @Override + public Result getExamineeAccountDetails(final String examineeUserId) { + // TODO Auto-generated method stub + return null; + } + + private void initRestTemplateAndRequestAccessToken() { + + log.info("Initialize Rest Template for OpenEdX API access. LmsSetup: {}", this.lmsSetup); + + if (this.restTemplate != null) { + try { + this.restTemplate.getAccessToken(); + return; + } catch (final Exception e) { + log.warn( + "Error while trying to get access token within already existing OAuth2RestTemplate instance. Try to create new one.", + e); + this.restTemplate = null; + } + } + + final Iterator tokenAccessPaths = this.knownTokenAccessPaths.iterator(); + while (this.restTemplate == null && tokenAccessPaths.hasNext()) { + final String accessTokenRequestPath = tokenAccessPaths.next(); + try { + final OAuth2RestTemplate template = createRestTemplate(accessTokenRequestPath); + final OAuth2AccessToken accessToken = template.getAccessToken(); + if (accessToken != null) { + this.restTemplate = template; + storeAccessToken(accessToken); + } + } catch (final Exception e) { + log.info("Failed to request access token on access token request path: {}", accessTokenRequestPath, e); + } + } + } + + private OAuth2RestTemplate createRestTemplate(final String accessTokenRequestPath) { + + final String lmsAuthSecret = this.internalEncryptionService.decrypt(this.lmsSetup.lmsAuthSecret); + + final ClientCredentialsResourceDetails details = new ClientCredentialsResourceDetails(); + details.setAccessTokenUri(this.lmsSetup.lmsApiUrl + accessTokenRequestPath); + details.setClientId(this.lmsSetup.lmsAuthName); + details.setClientSecret(lmsAuthSecret); + details.setGrantType("client_credentials"); + + // TODO: accordingly to the documentation (https://course-catalog-api-guide.readthedocs.io/en/latest/authentication/#create-an-account-on-edx-org-for-api-access) + // token_type=jwt is needed for token request but is it possible to set this within ClientCredentialsResourceDetails + // or within the request header on API call. To clarify + + final OAuth2RestTemplate template = new OAuth2RestTemplate(details); + template.setRequestFactory(this.clientHttpRequestFactory); + + final OAuth2AccessToken previousAccessToken = loadPreviousAccessToken(); + if (previousAccessToken != null) { + template.getOAuth2ClientContext().setAccessToken(previousAccessToken); + } + return template; + } + + private void storeAccessToken(final OAuth2AccessToken accessToken) { + try { + + final String accessTokenString = accessToken.getValue(); + final TextEncryptor textEncryptor = Encryptors.text(this.lmsSetup.lmsAuthSecret, this.lmsSetup.lmsAuthName); + final String accessTokenEncrypted = textEncryptor.encrypt(accessTokenString); + + // TODO store the accessTokenEncrypted to additional attributes of LmsSetup + + } catch (final Exception e) { + log.warn("Failed to store access token for later use.", e); + } + } + + private OAuth2AccessToken loadPreviousAccessToken() { + + // TODO get the previous token from additional attributes of LmsSetup + final String prevTokenEncrypted = null; + + if (StringUtils.isBlank(prevTokenEncrypted)) { + return null; + } + + try { + + final TextEncryptor textEncryptor = Encryptors.text(this.lmsSetup.lmsAuthSecret, this.lmsSetup.lmsAuthName); + final String prevTokenDecrypt = textEncryptor.decrypt(prevTokenEncrypted); + return new DefaultOAuth2AccessToken(prevTokenDecrypt); + + } catch (final Exception e) { + log.warn("Failed to decrypt previous access-token.", e); + return null; + } + } + + private QuizData quizDataOf(final CourseData courseData) { + final String startURI = this.lmsSetup.lmsApiUrl + OPEN_EDX_DEFAULT_COURSE_START_URL_PREFIX + courseData.id; + return new QuizData( + courseData.id, + courseData.name, + courseData.short_description, + courseData.start, + courseData.end, + startURI); + } + + static final class EdXPage { + public Integer count; + public Integer previous; + public Integer num_pages; + public Integer next; + public List results; + } + + static final class CourseData { + public String id; + public String course_id; + public String name; + public String short_description; + public String blocks_url; + public String start; + public String end; + } + + /* + * pagination + * count 2 + * previous null + * num_pages 1 + * next null + * results + * 0 + * blocks_url "http://ralph.ethz.ch:18000/api/courses/v1/blocks/?course_id=course-v1%3AedX%2BDemoX%2BDemo_Course" + * effort null + * end null + * enrollment_start null + * enrollment_end null + * id "course-v1:edX+DemoX+Demo_Course" + * media + * course_image + * uri "/asset-v1:edX+DemoX+Demo_Course+type@asset+block@images_course_image.jpg" + * course_video + * uri null + * image + * raw "http://ralph.ethz.ch:18000/asset-v1:edX+DemoX+Demo_Course+type@asset+block@images_course_image.jpg" + * small "http://ralph.ethz.ch:18000/asset-v1:edX+DemoX+Demo_Course+type@asset+block@images_course_image.jpg" + * large "http://ralph.ethz.ch:18000/asset-v1:edX+DemoX+Demo_Course+type@asset+block@images_course_image.jpg" + * name "edX Demonstration Course" + * number "DemoX" + * org "edX" + * short_description null + * start "2013-02-05T05:00:00Z" + * start_display "Feb. 5, 2013" + * start_type "timestamp" + * pacing "instructor" + * mobile_available false + * hidden false + * invitation_only false + * course_id "course-v1:edX+DemoX+Demo_Course" + */ + +} diff --git a/src/main/java/ch/ethz/seb/sebserver/webservice/weblayer/api/LmsSetupController.java b/src/main/java/ch/ethz/seb/sebserver/webservice/weblayer/api/LmsSetupController.java index 5969a7c9..bcd1b192 100644 --- a/src/main/java/ch/ethz/seb/sebserver/webservice/weblayer/api/LmsSetupController.java +++ b/src/main/java/ch/ethz/seb/sebserver/webservice/weblayer/api/LmsSetupController.java @@ -25,6 +25,7 @@ import ch.ethz.seb.sebserver.gbl.api.POSTMapper; import ch.ethz.seb.sebserver.gbl.api.SEBServerRestEndpoints; import ch.ethz.seb.sebserver.gbl.model.EntityType; import ch.ethz.seb.sebserver.gbl.model.institution.LmsSetup; +import ch.ethz.seb.sebserver.gbl.model.institution.LmsSetupTestResult; import ch.ethz.seb.sebserver.gbl.profile.WebServiceProfile; import ch.ethz.seb.sebserver.webservice.datalayer.batis.mapper.LmsSetupRecordDynamicSqlSupport; import ch.ethz.seb.sebserver.webservice.servicelayer.PaginationService; @@ -100,6 +101,21 @@ public class LmsSetupController extends ActivatableEntityController template.testLmsSetup()) + .getOrThrow(); + } + @Override protected LmsSetup createNew(final POSTMapper postParams) { return new LmsSetup(null, postParams); diff --git a/src/main/java/ch/ethz/seb/sebserver/webservice/weblayer/api/QuizImportController.java b/src/main/java/ch/ethz/seb/sebserver/webservice/weblayer/api/QuizImportController.java index 26e6e441..fb1ef5f8 100644 --- a/src/main/java/ch/ethz/seb/sebserver/webservice/weblayer/api/QuizImportController.java +++ b/src/main/java/ch/ethz/seb/sebserver/webservice/weblayer/api/QuizImportController.java @@ -14,8 +14,8 @@ import org.springframework.web.bind.annotation.RequestMethod; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; -import ch.ethz.seb.sebserver.gbl.model.Domain.LMS_SETUP; import ch.ethz.seb.sebserver.gbl.api.SEBServerRestEndpoints; +import ch.ethz.seb.sebserver.gbl.model.Domain.LMS_SETUP; import ch.ethz.seb.sebserver.gbl.model.EntityType; import ch.ethz.seb.sebserver.gbl.model.Page; import ch.ethz.seb.sebserver.gbl.model.exam.QuizData; @@ -78,7 +78,8 @@ public class QuizImportController { ? (pageSize <= this.maxPageSize) ? pageSize : this.maxPageSize - : this.defaultPageSize); + : this.defaultPageSize) + .getOrThrow(); } } diff --git a/src/main/resources/config/application.properties b/src/main/resources/config/application.properties index 0d4b74f9..ecfd9338 100644 --- a/src/main/resources/config/application.properties +++ b/src/main/resources/config/application.properties @@ -1,4 +1,7 @@ spring.application.name=SEB Server spring.profiles.active=dev -sebserver.version=1.0 beta \ No newline at end of file +sebserver.version=1.0 beta + +# comma separated list of known possible OpenEdX API access token request endpoints +sebserver.lms.openedix.api.token.request.paths=/oauth2/access_token \ No newline at end of file