first implementation of Open edX APITemplate

This commit is contained in:
anhefti 2019-01-31 22:45:37 +01:00
parent 3032720f28
commit 2855f93a55
16 changed files with 725 additions and 134 deletions

View file

@ -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);
}
}

View file

@ -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<String> 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<String> 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<String> 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<String> 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);
}
}

View file

@ -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 {
}

View file

@ -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 {
}

View file

@ -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);
}
}

View file

@ -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) {

View file

@ -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;
}
}
}

View file

@ -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);
// }
}

View file

@ -24,7 +24,7 @@ public interface LmsAPITemplate {
LmsSetupTestResult testLmsSetup();
Page<QuizData> getQuizzes(
Result<Page<QuizData>> getQuizzes(
String name,
Long from,
String sort,

View file

@ -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 {
}

View file

@ -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

View file

@ -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<QuizData> 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<QuizData> getQuizzes(
public Result<Page<QuizData>> 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<QuizData> quizzes = getQuizzes(name, from, sort);
final int numberOfPages = quizzes.size() / pageSize;
final Iterator<QuizData> iterator = quizzes.iterator();
final List<QuizData> 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<QuizData> quizzes = getQuizzes(name, from, sort);
final int numberOfPages = quizzes.size() / pageSize;
final Iterator<QuizData> iterator = quizzes.iterator();
final List<QuizData> 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

View file

@ -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<String> 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<String> 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<Page<QuizData>> 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<EdXPage> 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<QuizData> content = edxpage.results
.stream()
.reduce(
new ArrayList<QuizData>(),
(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<Result<QuizData>> getQuizzes(final Set<String> ids) {
// TODO Auto-generated method stub
return null;
}
@Override
public Result<ExamineeAccountDetails> 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<String> 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<CourseData> 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"
*/
}

View file

@ -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<LmsSetup, Lm
}
}
@RequestMapping(
path = "/connection_report/{id}",
method = RequestMethod.GET,
produces = MediaType.APPLICATION_JSON_UTF8_VALUE)
public LmsSetupTestResult connectionReport(@PathVariable final Long id) {
this.authorizationGrantService.checkHasAnyPrivilege(
EntityType.LMS_SETUP,
PrivilegeType.MODIFY);
return this.lmsAPIService.createLmsAPITemplate(id)
.map(template -> template.testLmsSetup())
.getOrThrow();
}
@Override
protected LmsSetup createNew(final POSTMapper postParams) {
return new LmsSetup(null, postParams);

View file

@ -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();
}
}

View file

@ -1,4 +1,7 @@
spring.application.name=SEB Server
spring.profiles.active=dev
sebserver.version=1.0 beta
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