SEBSERV-363 finished integration adaption and testing

This commit is contained in:
anhefti 2023-01-19 16:08:56 +01:00
parent edce7275ca
commit 4671e682a3
13 changed files with 209 additions and 124 deletions

View file

@ -126,7 +126,7 @@ public class ProctoringServiceSettings implements Entity {
this.examId = examId;
this.enableProctoring = BooleanUtils.isTrue(enableProctoring);
this.serverType = (serverType != null) ? serverType : ProctoringServerType.JITSI_MEET;
this.serverType = (serverType != null) ? serverType : ProctoringServerType.ZOOM;
this.serverURL = serverURL;
this.collectingRoomSize = (collectingRoomSize != null) ? collectingRoomSize : 20;
this.enabledFeatures = enabledFeatures != null ? enabledFeatures : EnumSet.allOf(ProctoringFeature.class);
@ -144,7 +144,7 @@ public class ProctoringServiceSettings implements Entity {
public ProctoringServiceSettings(final Long examId) {
this.examId = examId;
this.enableProctoring = false;
this.serverType = null;
this.serverType = ProctoringServerType.ZOOM;
this.serverURL = null;
this.collectingRoomSize = 20;
this.enabledFeatures = EnumSet.allOf(ProctoringFeature.class);

View file

@ -256,7 +256,6 @@ public class ExamSignatureKeyForm implements TemplateComposer {
.newAction(ActionDefinition.EXAM_SECURITY_KEY_BACK_MODIFY)
.withEntityKey(entityKey)
//.withExec(this.pageService.backToCurrentFunction())
.publishIf(() -> readonly)
.newAction(ActionDefinition.EXAM_SECURITY_KEY_SHOW_ADD_GRANT_POPUP)

View file

@ -111,6 +111,8 @@ public class ProctoringSettingsPopup {
new LocTextKey("sebserver.exam.proctoring.form.resetSettings");
private final static LocTextKey RESET_CONFIRM_KEY =
new LocTextKey("sebserver.exam.proctoring.form.resetConfirm");
private final static LocTextKey RESET_ACTIVE_CON_KEY =
new LocTextKey("sebserver.exam.proctoring.form.resetActive");
Function<PageAction, PageAction> settingsFunction(final PageService pageService, final boolean modifyGrant) {
@ -171,7 +173,7 @@ public class ProctoringSettingsPopup {
pc -> new SEBProctoringPropertiesForm(
pageService,
pageContext,
resetButtonHandler));
resetButtonHandler).compose(pc.getParent()));
}
return action;
@ -207,8 +209,14 @@ public class ProctoringSettingsPopup {
.withURIVariable(API.PARAM_MODEL_ID, entityKey.modelId)
.call()
.onError(error -> {
log.error("Failed to rest proctoring settings for exam: {}", entityKey, error);
pageContext.notifyUnexpectedError(error);
if (error.getMessage().contains("active connections") ||
(error.getCause() != null &&
error.getCause().getMessage().contains("active connections"))) {
pageContext.publishInfo(RESET_ACTIVE_CON_KEY);
} else {
log.error("Failed to rest proctoring settings for exam: {}", entityKey, error);
pageContext.notifyUnexpectedError(error);
}
}).map(settings -> true).getOr(false);
}

View file

@ -10,6 +10,7 @@ package ch.ethz.seb.sebserver.webservice.servicelayer.dao;
import java.util.Collection;
import java.util.Map;
import java.util.Objects;
import java.util.stream.Collectors;
import org.slf4j.Logger;
@ -102,10 +103,37 @@ public interface AdditionalAttributesDAO {
final Long entityId,
final Map<String, String> attributes) {
return saveAdditionalAttributes(type, entityId, attributes, false);
}
/** Use this to save an additional attributes for a specific entity.
* If an additional attribute with specified name already exists for the specified entity
* this updates just the value for this additional attribute. Otherwise create a new instance
* of additional attribute with the given data
*
* @param type the entity type
* @param entityId the entity identifier (primary key)
* @param attributes Map of attributes to save for
* @param deleteNullValues indicates if null values shall be deleted or not */
default Result<Collection<AdditionalAttributeRecord>> saveAdditionalAttributes(
final EntityType type,
final Long entityId,
final Map<String, String> attributes,
final boolean deleteNullValues) {
return Result.tryCatch(() -> attributes.entrySet()
.stream()
.map(attr -> saveAdditionalAttribute(type, entityId, attr.getKey(), attr.getValue())
.onError(error -> log.warn("Failed to save additional attribute: {}", error.getMessage())))
.map(attr -> {
if (deleteNullValues && attr.getValue() == null) {
delete(type, entityId, attr.getKey());
return null;
} else {
return saveAdditionalAttribute(type, entityId, attr.getKey(), attr.getValue())
.onError(error -> log.warn("Failed to save additional attribute: {}",
error.getMessage()));
}
})
.filter(Objects::nonNull)
.flatMap(Result::skipOnError)
.collect(Collectors.toList()));
}

View file

@ -116,6 +116,10 @@ public interface ExamAdminService {
* @return ExamProctoringService instance */
Result<ExamProctoringService> getExamProctoringService(final Long examId);
/** This resets the proctoring settings for a given exam and stores the default settings.
*
* @param exam The exam reference
* @return Result refer to the given exam or to an error when happened */
Result<Exam> resetProctoringSettings(Exam exam);
/** This archives a finished exam and set it to archived state as well as the assigned

View file

@ -245,6 +245,9 @@ public class ExamAdminServiceImpl implements ExamAdminService {
@Override
public Result<Exam> resetProctoringSettings(final Exam exam) {
// first delete all proctoring settings
return getProctoringServiceSettings(exam.id)
.map(settings -> {
ProctoringServiceSettings resetSettings;

View file

@ -10,6 +10,7 @@ package ch.ethz.seb.sebserver.webservice.servicelayer.exam.impl;
import java.util.Arrays;
import java.util.EnumSet;
import java.util.HashMap;
import java.util.Map;
import java.util.Objects;
import java.util.function.Function;
@ -100,7 +101,6 @@ public class ProctoringAdminServiceImpl implements ProctoringAdminService {
ProctoringServiceSettings.ATTR_USE_ZOOM_APP_CLIENT_COLLECTING_ROOM));
})
.getOrThrow();
});
}
@ -118,95 +118,53 @@ public class ProctoringAdminServiceImpl implements ProctoringAdminService {
testExamProctoring(proctoringServiceSettings).getOrThrow();
}
this.additionalAttributesDAO.saveAdditionalAttribute(
parentEntityKey.entityType,
entityId,
final Map<String, String> attributes = new HashMap<>();
attributes.put(
ProctoringServiceSettings.ATTR_ENABLE_PROCTORING,
String.valueOf(proctoringServiceSettings.enableProctoring));
this.additionalAttributesDAO.saveAdditionalAttribute(
parentEntityKey.entityType,
entityId,
attributes.put(
ProctoringServiceSettings.ATTR_SERVER_TYPE,
proctoringServiceSettings.serverType.name());
this.additionalAttributesDAO.saveAdditionalAttribute(
parentEntityKey.entityType,
entityId,
attributes.put(
ProctoringServiceSettings.ATTR_SERVER_URL,
StringUtils.trim(proctoringServiceSettings.serverURL));
this.additionalAttributesDAO.saveAdditionalAttribute(
parentEntityKey.entityType,
entityId,
attributes.put(
ProctoringServiceSettings.ATTR_COLLECTING_ROOM_SIZE,
String.valueOf(proctoringServiceSettings.collectingRoomSize));
if (StringUtils.isNotBlank(proctoringServiceSettings.appKey)) {
this.additionalAttributesDAO.saveAdditionalAttribute(
parentEntityKey.entityType,
entityId,
ProctoringServiceSettings.ATTR_APP_KEY,
StringUtils.trim(proctoringServiceSettings.appKey));
this.additionalAttributesDAO.saveAdditionalAttribute(
parentEntityKey.entityType,
entityId,
ProctoringServiceSettings.ATTR_APP_SECRET,
this.cryptor.encrypt(Utils.trim(proctoringServiceSettings.appSecret))
.getOrThrow()
.toString());
}
if (StringUtils.isNotBlank(proctoringServiceSettings.accountId)) {
this.additionalAttributesDAO.saveAdditionalAttribute(
parentEntityKey.entityType,
entityId,
ProctoringServiceSettings.ATTR_ACCOUNT_ID,
StringUtils.trim(proctoringServiceSettings.accountId));
this.additionalAttributesDAO.saveAdditionalAttribute(
parentEntityKey.entityType,
entityId,
ProctoringServiceSettings.ATTR_ACCOUNT_CLIENT_ID,
StringUtils.trim(proctoringServiceSettings.clientId));
this.additionalAttributesDAO.saveAdditionalAttribute(
parentEntityKey.entityType,
entityId,
ProctoringServiceSettings.ATTR_ACCOUNT_CLIENT_SECRET,
this.cryptor.encrypt(Utils.trim(proctoringServiceSettings.clientSecret))
.getOrThrow()
.toString());
}
if (StringUtils.isNotBlank(proctoringServiceSettings.sdkKey)) {
this.additionalAttributesDAO.saveAdditionalAttribute(
parentEntityKey.entityType,
entityId,
ProctoringServiceSettings.ATTR_SDK_KEY,
StringUtils.trim(proctoringServiceSettings.sdkKey));
this.additionalAttributesDAO.saveAdditionalAttribute(
parentEntityKey.entityType,
entityId,
ProctoringServiceSettings.ATTR_SDK_SECRET,
this.cryptor.encrypt(Utils.trim(proctoringServiceSettings.sdkSecret))
.getOrThrow()
.toString());
}
this.additionalAttributesDAO.saveAdditionalAttribute(
parentEntityKey.entityType,
entityId,
attributes.put(
ProctoringServiceSettings.ATTR_APP_KEY,
StringUtils.trim(proctoringServiceSettings.appKey));
attributes.put(
ProctoringServiceSettings.ATTR_APP_SECRET,
encryptSecret(Utils.trim(proctoringServiceSettings.appSecret)));
attributes.put(
ProctoringServiceSettings.ATTR_ACCOUNT_ID,
StringUtils.trim(proctoringServiceSettings.accountId));
attributes.put(
ProctoringServiceSettings.ATTR_ACCOUNT_CLIENT_ID,
StringUtils.trim(proctoringServiceSettings.clientId));
attributes.put(
ProctoringServiceSettings.ATTR_ACCOUNT_CLIENT_SECRET,
encryptSecret(Utils.trim(proctoringServiceSettings.clientSecret)));
attributes.put(
ProctoringServiceSettings.ATTR_SDK_KEY,
StringUtils.trim(proctoringServiceSettings.sdkKey));
attributes.put(
ProctoringServiceSettings.ATTR_SDK_SECRET,
encryptSecret(Utils.trim(proctoringServiceSettings.sdkSecret)));
attributes.put(
ProctoringServiceSettings.ATTR_ENABLED_FEATURES,
StringUtils.join(proctoringServiceSettings.enabledFeatures, Constants.LIST_SEPARATOR));
this.additionalAttributesDAO.saveAdditionalAttribute(
parentEntityKey.entityType,
entityId,
attributes.put(
ProctoringServiceSettings.ATTR_USE_ZOOM_APP_CLIENT_COLLECTING_ROOM,
String.valueOf(proctoringServiceSettings.useZoomAppClientForCollectingRoom));
this.additionalAttributesDAO.saveAdditionalAttributes(
parentEntityKey.entityType,
entityId,
attributes,
true);
return proctoringServiceSettings;
});
}
@ -293,4 +251,13 @@ public class ProctoringAdminServiceImpl implements ProctoringAdminService {
}
}
private String encryptSecret(final CharSequence secret) {
if (StringUtils.isBlank(secret)) {
return null;
}
return this.cryptor.encrypt(Utils.trim(secret))
.getOrThrow()
.toString();
}
}

View file

@ -139,6 +139,11 @@ public interface ExamProctoringRoomService {
* @return Result refer to void or to an error when happened */
Result<Void> notifyRoomOpened(Long examId, String roomName);
/** Disposes all open proctoring rooms for a given exam and removes the references on the persistent storage.
* First checks if there are active SEB client connections on for the given exam and if so, throws error.
*
* @param exam The exam to cleanup the rooms
* @return Result refer to the given exam or to an error when happened */
Result<Exam> cleanupAllRooms(Exam exam);
}

View file

@ -125,4 +125,6 @@ public interface ExamProctoringService {
RemoteProctoringRoom room,
Collection<ClientConnection> clientConnections);
public void clearRestTemplateCache(final Long examId);
}

View file

@ -351,6 +351,11 @@ public class JitsiProctoringService implements ExamProctoringService {
return Result.EMPTY;
}
@Override
public void clearRestTemplateCache(final Long examId) {
// Nothing to do here
}
protected Result<ProctoringRoomConnection> createProctoringConnection(
final String connectionToken,
final String url,

View file

@ -15,7 +15,9 @@ import java.util.Base64;
import java.util.Base64.Encoder;
import java.util.Collection;
import java.util.HashMap;
import java.util.Iterator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;
@ -38,11 +40,22 @@ import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.security.oauth2.client.DefaultOAuth2ClientContext;
import org.springframework.http.client.SimpleClientHttpRequestFactory;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.oauth2.client.OAuth2RestTemplate;
import org.springframework.security.oauth2.client.resource.BaseOAuth2ProtectedResourceDetails;
import org.springframework.security.oauth2.client.token.DefaultAccessTokenRequest;
import org.springframework.security.oauth2.client.resource.OAuth2AccessDeniedException;
import org.springframework.security.oauth2.client.resource.OAuth2ProtectedResourceDetails;
import org.springframework.security.oauth2.client.resource.UserRedirectRequiredException;
import org.springframework.security.oauth2.client.token.AccessTokenProvider;
import org.springframework.security.oauth2.client.token.AccessTokenRequest;
import org.springframework.security.oauth2.client.token.OAuth2AccessTokenSupport;
import org.springframework.security.oauth2.client.token.grant.client.ClientCredentialsResourceDetails;
import org.springframework.security.oauth2.common.OAuth2AccessToken;
import org.springframework.security.oauth2.common.OAuth2RefreshToken;
import org.springframework.stereotype.Service;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.web.client.RestClientResponseException;
import org.springframework.web.client.RestTemplate;
import org.springframework.web.util.UriComponentsBuilder;
@ -196,13 +209,10 @@ public class ZoomProctoringService implements ExamProctoringService {
final ResponseEntity<String> result = newRestTemplate.testServiceConnection();
if (result.getStatusCode() != HttpStatus.OK) {
throw new APIMessageException(Arrays.asList(
APIMessage.fieldValidationError(ProctoringServiceSettings.ATTR_SERVER_URL,
"proctoringSettings:serverURL:url.invalid"),
APIMessage.ErrorMessage.EXTERNAL_SERVICE_BINDING_ERROR.of()));
throw new RuntimeException("Invalid Zoom Service response: " + result);
}
} catch (final Exception e) {
log.error("Failed to access Zoom service at: {}", proctoringSettings.serverURL, e.getMessage());
log.error("Failed to access Zoom service at: {}", proctoringSettings.serverURL, e);
throw new APIMessageException(Arrays.asList(
APIMessage.fieldValidationError(ProctoringServiceSettings.ATTR_SERVER_URL,
"proctoringSettings:serverURL:url.noservice"),
@ -655,35 +665,17 @@ public class ZoomProctoringService implements ExamProctoringService {
// NOTE: following is the original code that includes the exam end time but seems to make trouble for OLAT
final long nowInSeconds = Utils.getSecondsNow();
// final long nowPlus30MinInSeconds = nowInSeconds + Utils.toSeconds(30 * Constants.MINUTE_IN_MILLIS);
final long nowPlusOneDayInSeconds = nowInSeconds + Utils.toSeconds(Constants.DAY_IN_MILLIS);
// final long nowPlusTwoDayInSeconds = nowInSeconds + Utils.toSeconds(2 * Constants.DAY_IN_MILLIS);
// long expTime = nowPlusOneDayInSeconds;
// if (examProctoring.examId == null && this.examSessionService.isExamRunning(examProctoring.examId)) {
// final Exam exam = this.examSessionService.getRunningExam(examProctoring.examId)
// .getOrThrow();
// if (exam.endTime != null) {
// expTime = Utils.toSeconds(exam.endTime.getMillis());
// }
// }
// // refer to https://marketplace.zoom.us/docs/sdk/native-sdks/auth
// // "exp": 0, //JWT expiration date (Min:1800 seconds greater than iat value, Max: 48 hours greater than iat value) in epoch format.
// if (expTime >= nowPlusTwoDayInSeconds) {
// expTime = nowPlusTwoDayInSeconds - 10; // Do not set to max because it is not well defined if max is included or not
// } else if (expTime < nowPlus30MinInSeconds) {
// expTime = nowPlusOneDayInSeconds;
// }
//
// log.debug("**** SDK Token exp time with exam-end-time inclusion would be: {}", expTime);
//
//
// NOTE: Set this to the maximum according to https://marketplace.zoom.us/docs/sdk/native-sdks/auth
//return nowPlusTwoDayInSeconds - 1000; // Do not set to max because it is not well defined if max is included or not;
// NOTE: It seems that since the update of web sdk to SDKToken to 1.7.0, the max is new + one day
return nowPlusOneDayInSeconds - 10;
}
@Override
public synchronized void clearRestTemplateCache(final Long examId) {
this.restTemplatesCache.remove(examId);
}
private final LinkedHashMap<Long, ZoomRestTemplate> restTemplatesCache = new LinkedHashMap<>();
private synchronized ZoomRestTemplate getZoomRestTemplate(final ProctoringServiceSettings proctoringSettings) {
@ -828,7 +820,7 @@ public class ZoomProctoringService implements ExamProctoringService {
final HttpHeaders headers = getHeaders();
headers.set(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE);
final ResponseEntity<String> exchange = exchange(url, HttpMethod.PATCH, body, headers);
final ResponseEntity<String> exchange = exchange(url, HttpMethod.POST, body, headers);
return exchange;
} catch (final Exception e) {
log.error("Failed to apply user settings for Zoom user: {}", userId, e);
@ -1006,17 +998,19 @@ public class ZoomProctoringService implements ExamProctoringService {
.decrypt(this.credentials.secret)
.getOrThrow();
this.resource = new BaseOAuth2ProtectedResourceDetails();
this.resource = new ClientCredentialsResourceDetails();
this.resource.setAccessTokenUri(this.proctoringSettings.serverURL + "/oauth/token");
this.resource.setClientId(this.credentials.clientIdAsString());
this.resource.setClientSecret(decryptedSecret.toString());
this.resource.setGrantType("account_credentials");
this.resource.setId(this.proctoringSettings.accountId);
final DefaultAccessTokenRequest defaultAccessTokenRequest = new DefaultAccessTokenRequest();
defaultAccessTokenRequest.set("account_id", this.proctoringSettings.accountId);
this.restTemplate = new OAuth2RestTemplate(
this.resource,
new DefaultOAuth2ClientContext(defaultAccessTokenRequest));
final SimpleClientHttpRequestFactory requestFactory = new SimpleClientHttpRequestFactory();
requestFactory.setOutputStreaming(false);
final OAuth2RestTemplate oAuth2RestTemplate = new OAuth2RestTemplate(this.resource);
oAuth2RestTemplate.setRequestFactory(requestFactory);
oAuth2RestTemplate.setAccessTokenProvider(new ZoomCredentialsAccessTokenProvider());
this.restTemplate = oAuth2RestTemplate;
}
}
@ -1116,4 +1110,63 @@ public class ZoomProctoringService implements ExamProctoringService {
}
}
private static final class ZoomCredentialsAccessTokenProvider extends OAuth2AccessTokenSupport
implements AccessTokenProvider {
@Override
public boolean supportsResource(final OAuth2ProtectedResourceDetails resource) {
return resource instanceof ClientCredentialsResourceDetails
&& "account_credentials".equals(resource.getGrantType());
}
@Override
public boolean supportsRefresh(final OAuth2ProtectedResourceDetails resource) {
return false;
}
@Override
public OAuth2AccessToken refreshAccessToken(final OAuth2ProtectedResourceDetails resource,
final OAuth2RefreshToken refreshToken, final AccessTokenRequest request)
throws UserRedirectRequiredException {
return null;
}
@Override
public OAuth2AccessToken obtainAccessToken(final OAuth2ProtectedResourceDetails details,
final AccessTokenRequest request)
throws UserRedirectRequiredException, AccessDeniedException, OAuth2AccessDeniedException {
final ClientCredentialsResourceDetails resource = (ClientCredentialsResourceDetails) details;
return retrieveToken(request, resource, getParametersForTokenRequest(resource), new HttpHeaders());
}
private MultiValueMap<String, String> getParametersForTokenRequest(
final ClientCredentialsResourceDetails resource) {
final MultiValueMap<String, String> form = new LinkedMultiValueMap<>();
form.set("grant_type", "account_credentials");
form.set("account_id", resource.getId());
if (resource.isScoped()) {
final StringBuilder builder = new StringBuilder();
final List<String> scope = resource.getScope();
if (scope != null) {
final Iterator<String> scopeIt = scope.iterator();
while (scopeIt.hasNext()) {
builder.append(scopeIt.next());
if (scopeIt.hasNext()) {
builder.append(' ');
}
}
}
form.set("scope", builder.toString());
}
return form;
}
}
}

View file

@ -22,6 +22,8 @@ import javax.validation.Valid;
import org.apache.commons.lang3.StringUtils;
import org.joda.time.DateTime;
import org.mybatis.dynamic.sql.SqlTable;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.MediaType;
import org.springframework.validation.FieldError;
import org.springframework.web.bind.annotation.PathVariable;
@ -80,6 +82,8 @@ import ch.ethz.seb.sebserver.webservice.servicelayer.validation.BeanValidationSe
@RequestMapping("${sebserver.webservice.api.admin.endpoint}" + API.EXAM_ADMINISTRATION_ENDPOINT)
public class ExamAdministrationController extends EntityController<Exam, Exam> {
private static final Logger log = LoggerFactory.getLogger(ExamAdministrationController.class);
private final ExamDAO examDAO;
private final UserDAO userDAO;
private final ExamAdminService examAdminService;
@ -508,9 +512,16 @@ public class ExamAdministrationController extends EntityController<Exam, Exam> {
return this.entityDAO
.byPK(examId)
.flatMap(this.examProctoringRoomService::cleanupAllRooms)
.map(exam -> {
this.examAdminService.getExamProctoringService(exam.id)
.onSuccess(service -> service.clearRestTemplateCache(exam.id))
.onError(error -> log.warn(
"Failed to clear proctoring rest template cache for exam: {}",
error.getMessage()));
return exam;
})
.flatMap(this.examAdminService::resetProctoringSettings)
.getOrThrow();
}
// **** Proctoring

View file

@ -831,7 +831,7 @@ sebserver.exam.proctoring.form.useZoomAppClient.tooltip=If this is set SEB Serve
sebserver.exam.proctoring.form.saveSettings=Save Settings
sebserver.exam.proctoring.form.resetSettings=Reset Settings
sebserver.exam.proctoring.form.resetConfirm=Reset will first cleanup and remove all existing proctoring rooms for this exam and then reset to default or template settings.<br/>Please make sure there are no active SEB client connections for this exam, otherwise this reset will be denied.<br/><br/>Do you want to reset proctoring for this exam now?
sebserver.exam.proctoring.form.resetActive=There are still active SEB client connection for this exam. Please disconnect or cancel them first.<br/>This action is only possible when there are no active SEB client connection within this exam.
sebserver.exam.proctoring.type.servertype.JITSI_MEET=Jitsi Meet Server
sebserver.exam.proctoring.type.servertype.JITSI_MEET.tooltip=Use a Jitsi Meet server for proctoring