zoom integration

This commit is contained in:
anhefti 2021-03-16 17:34:23 +01:00
parent 564b7ec9c9
commit 43383bcebb
13 changed files with 574 additions and 281 deletions

View file

@ -53,6 +53,7 @@ public final class Constants {
public static final Character SLASH = '/';
public static final Character BACKSLASH = '\\';
public static final Character QUOTE = '\'';
public static final Character QUERY = '?';
public static final Character DOUBLE_QUOTE = '"';
public static final Character COMMA = ',';
public static final Character PIPE = '|';

View file

@ -24,6 +24,7 @@ public class ProctoringRoomConnection {
public static final String ATTR_SUBJECT = "subject";
public static final String ATTR_ACCESS_TOKEN = "accessToken";
public static final String ATTR_CONNECTION_URL = "connectionURL";
public static final String ATTR_USER_NAME = "userName";
@JsonProperty(ProctoringServiceSettings.ATTR_SERVER_TYPE)
public final ProctoringServerType proctoringServerType;
@ -46,6 +47,9 @@ public class ProctoringRoomConnection {
@JsonProperty(ATTR_ACCESS_TOKEN)
public final String accessToken;
@JsonProperty(ATTR_USER_NAME)
public final String userName;
@JsonCreator
public ProctoringRoomConnection(
@JsonProperty(ProctoringServiceSettings.ATTR_SERVER_TYPE) final ProctoringServerType proctoringServerType,
@ -54,7 +58,8 @@ public class ProctoringRoomConnection {
@JsonProperty(ATTR_SERVER_URL) final String serverURL,
@JsonProperty(ATTR_ROOM_NAME) final String roomName,
@JsonProperty(ATTR_SUBJECT) final String subject,
@JsonProperty(ATTR_ACCESS_TOKEN) final String accessToken) {
@JsonProperty(ATTR_ACCESS_TOKEN) final String accessToken,
@JsonProperty(ATTR_USER_NAME) final String userName) {
this.proctoringServerType = proctoringServerType;
this.connectionToken = connectionToken;
@ -63,6 +68,7 @@ public class ProctoringRoomConnection {
this.roomName = roomName;
this.subject = subject;
this.accessToken = accessToken;
this.userName = userName;
}
public ProctoringServerType getProctoringServerType() {

View file

@ -51,6 +51,7 @@ public final class ClientInstruction {
public static final String ZOOM_URL = "zoomMeetServerURL";
public static final String ZOOM_ROOM = "zoomMeetRoom";
public static final String ZOOM_ROOM_SUBJECT = "zoomMeetSubject";
public static final String ZOOM_USER_NAME = "zoomUserName";
public static final String ZOOM_TOKEN = "zoomMeetToken";
public static final String ZOOM_RECEIVE_AUDIO = "zoomMeetReceiveAudio";
public static final String ZOOM_RECEIVE_VIDEO = "zoomMeetReceiveVideo";

View file

@ -686,5 +686,4 @@ public final class Utils {
public static String valueOrEmptyNote(final String value) {
return StringUtils.isBlank(value) ? Constants.EMPTY_NOTE : value;
}
}

View file

@ -20,6 +20,8 @@ public interface RemoteProctoringRoomDAO {
Result<Collection<RemoteProctoringRoom>> getCollectingRoomsForExam(Long examId);
//Result<Collection<RemoteProctoringRoom>> getRoomsOfExam(Long examId);
Result<RemoteProctoringRoom> getRoom(Long roomId);
Result<RemoteProctoringRoom> getRoom(Long examId, String roomName);

View file

@ -44,51 +44,20 @@ public interface ExamProctoringService {
String roomName,
String subject);
Result<ProctoringRoomConnection> getClientBreakOutRoomConnection(
Result<ProctoringRoomConnection> getClientRoomConnection(
final ProctoringServiceSettings proctoringSettings,
final String connectionToken,
final String roomName,
final String subject);
Result<ProctoringRoomConnection> getClientCollectingRoomConnection(
final ProctoringServiceSettings proctoringSettings,
final String connectionToken,
final String roomName,
final String subject);
// Result<ProctoringRoomConnection> getClientCollectingRoomConnection(
// final ProctoringServiceSettings proctoringSettings,
// final String connectionToken,
// final String roomName,
// final String subject);
Map<String, String> createJoinInstructionAttributes(final ProctoringRoomConnection proctoringConnection);
// /** This instructs all sepcified SEB clients to join a defined room by creating a individual room access token
// * and join instruction for each client and put this instruction to the clients instruction queue.
// *
// * @param proctoringSettings The proctoring service settings
// * @param clientConnectionTokens A collection of SEB connection tokens. Only active SEB clients will get the
// * instructions
// * @param roomName The name of the room to join
// * @param subject the subject of the room to join
// * @return Result refer to the room connection data for the proctor to join to room too or to an error when
// * happened */
// Result<ProctoringRoomConnection> sendJoinRoomToClients(
// ProctoringServiceSettings proctoringSettings,
// Collection<String> clientConnectionTokens,
// String roomName,
// String subject);
// /** Sends instructions to join or rejoin the individual assigned collecting rooms of each involved SEB client.
// * Creates an individual join instruction for each involved client and put that to the clients instruction queue.
// *
// * INFO:
// * A collecting room is assigned to each SEB client connection while connecting to the SEB server and
// * each SEB client that has successfully connected to the SEB Server and is participating in an exam
// * with proctoring enabled, is assigned to a collecting room.
// *
// * @param proctoringSettings he proctoring service settings
// * @param clientConnectionTokens A collection of SEB connection tokens. Only active SEB clients will get the
// * instructions
// * @return Empty Result that refers to an error when happened */
// Result<Void> sendJoinCollectingRoomToClients(
// ProctoringServiceSettings proctoringSettings,
// Collection<String> clientConnectionTokens);
Map<String, String> createJoinInstructionAttributes(
final ProctoringRoomConnection proctoringConnection);
Result<Void> disposeServiceRoomsForExam(Exam exam);
@ -100,11 +69,11 @@ public interface ExamProctoringService {
throw new RuntimeException("Test Why: " + connectionToken);
}
Result<NewRoom> newCollectingRoom(Long roomNumber);
Result<NewRoom> newCollectingRoom(ProctoringServiceSettings proctoringSettings, Long roomNumber);
Result<NewRoom> newBreakOutRoom(String subject);
Result<NewRoom> newBreakOutRoom(ProctoringServiceSettings proctoringSettings, String subject);
Result<Void> disposeBreakOutRoom(String roomName);
Result<Void> disposeBreakOutRoom(ProctoringServiceSettings proctoringSettings, String roomName);
Map<String, String> getDefaultInstructionAttributes();

View file

@ -149,7 +149,7 @@ public class ExamProctoringRoomServiceImpl implements ExamProctoringRoomService
// First create and get the town-hall room for specified exam
final RemoteProctoringRoom townhallRoom = examProctoringService
.newBreakOutRoom(subject)
.newBreakOutRoom(settings, subject)
.flatMap(room -> this.remoteProctoringRoomDAO.createTownhallRoom(examId, room))
.getOrThrow();
@ -187,7 +187,7 @@ public class ExamProctoringRoomServiceImpl implements ExamProctoringRoomService
.getOrThrow();
final RemoteProctoringRoom breakOutRoom = examProctoringService
.newBreakOutRoom(subject)
.newBreakOutRoom(settings, subject)
.flatMap(room -> this.remoteProctoringRoomDAO.createBreakOutRoom(examId, room, connectionTokens))
.getOrThrow();
@ -302,7 +302,9 @@ public class ExamProctoringRoomServiceImpl implements ExamProctoringRoomService
return this.remoteProctoringRoomDAO.reservePlaceInCollectingRoom(
examId,
proctoringSettings.collectingRoomSize,
examProctoringService::newCollectingRoom)
roomNumber -> examProctoringService.newCollectingRoom(
proctoringSettings,
roomNumber))
.getOrThrow();
} catch (final Exception e) {
@ -334,7 +336,9 @@ public class ExamProctoringRoomServiceImpl implements ExamProctoringRoomService
this.remoteProctoringRoomDAO
.getTownhallRoom(examId)
.map(RemoteProctoringRoom::getName)
.flatMap(examProctoringService::disposeBreakOutRoom)
.flatMap(roomName -> examProctoringService.disposeBreakOutRoom(
proctoringSettings,
roomName))
.flatMap(service -> this.remoteProctoringRoomDAO.deleteTownhallRoom(examId))
.getOrThrow();
@ -382,7 +386,8 @@ public class ExamProctoringRoomServiceImpl implements ExamProctoringRoomService
.getOrThrow();
// Dispose the proctoring room on service side
examProctoringService.disposeBreakOutRoom(remoteProctoringRoom.name)
examProctoringService
.disposeBreakOutRoom(proctoringSettings, remoteProctoringRoom.name)
.getOrThrow();
// Send join collecting rooms to involving clients
@ -448,12 +453,13 @@ public class ExamProctoringRoomServiceImpl implements ExamProctoringRoomService
connectionTokens
.stream()
.forEach(connectionToken -> {
this.sebInstructionService.registerInstruction(
examId,
InstructionType.SEB_RECONFIGURE_SETTINGS,
attributes,
connectionToken,
true)
this.sebInstructionService
.registerInstruction(
examId,
InstructionType.SEB_RECONFIGURE_SETTINGS,
attributes,
connectionToken,
true)
.onError(error -> log.error(
"Failed to register reconfiguring instruction for connection: {}",
connectionToken,
@ -496,17 +502,17 @@ public class ExamProctoringRoomServiceImpl implements ExamProctoringRoomService
clientConnectionTokens
.stream()
.forEach(connectionToken -> {
final ProctoringRoomConnection proctoringConnection =
examProctoringService.getClientBreakOutRoomConnection(
final ProctoringRoomConnection proctoringConnection = examProctoringService
.getClientRoomConnection(
proctoringSettings,
connectionToken,
examProctoringService.verifyRoomName(roomName, connectionToken),
(StringUtils.isNotBlank(subject)) ? subject : roomName)
.onError(error -> log.error(
"Failed to get client room connection data for {} cause: {}",
connectionToken,
error.getMessage()))
.get();
.onError(error -> log.error(
"Failed to get client room connection data for {} cause: {}",
connectionToken,
error.getMessage()))
.get();
if (proctoringConnection != null) {
sendJoinInstruction(
proctoringSettings.examId,
@ -551,7 +557,7 @@ public class ExamProctoringRoomServiceImpl implements ExamProctoringRoomService
.getOrThrow();
final ProctoringRoomConnection proctoringConnection = examProctoringService
.getClientCollectingRoomConnection(
.getClientRoomConnection(
proctoringSettings,
clientConnection.clientConnection.connectionToken,
roomName,
@ -577,12 +583,13 @@ public class ExamProctoringRoomServiceImpl implements ExamProctoringRoomService
final Map<String, String> attributes = examProctoringService
.createJoinInstructionAttributes(proctoringConnection);
this.sebInstructionService.registerInstruction(
examId,
InstructionType.SEB_PROCTORING,
attributes,
connectionToken,
true)
this.sebInstructionService
.registerInstruction(
examId,
InstructionType.SEB_PROCTORING,
attributes,
connectionToken,
true)
.onError(error -> log.error("Failed to send join instruction: {}", connectionToken, error));
}
}

View file

@ -170,21 +170,30 @@ public class JitsiProctoringService implements ExamProctoringService {
}
@Override
public Result<NewRoom> newCollectingRoom(final Long roomNumber) {
public Result<NewRoom> newCollectingRoom(
final ProctoringServiceSettings proctoringSettings,
final Long roomNumber) {
return Result.of(new NewRoom(
UUID.randomUUID().toString(),
"Room " + (roomNumber + 1)));
}
@Override
public Result<NewRoom> newBreakOutRoom(final String subject) {
public Result<NewRoom> newBreakOutRoom(
final ProctoringServiceSettings proctoringSettings,
final String subject) {
return Result.of(new NewRoom(
UUID.randomUUID().toString(),
subject));
}
@Override
public Result<Void> disposeBreakOutRoom(final String roomName) {
public Result<Void> disposeBreakOutRoom(
final ProctoringServiceSettings proctoringSettings,
final String roomName) {
return Result.EMPTY;
}
@ -237,7 +246,6 @@ public class JitsiProctoringService implements ExamProctoringService {
return Result.tryCatch(() -> {
return createProctoringConnection(
proctoringSettings.serverType,
null,
proctoringSettings.serverURL,
proctoringSettings.appKey,
@ -253,7 +261,7 @@ public class JitsiProctoringService implements ExamProctoringService {
}
@Override
public Result<ProctoringRoomConnection> getClientCollectingRoomConnection(
public Result<ProctoringRoomConnection> getClientRoomConnection(
final ProctoringServiceSettings proctoringSettings,
final String connectionToken,
final String roomName,
@ -265,7 +273,6 @@ public class JitsiProctoringService implements ExamProctoringService {
.getOrThrow();
return createProctoringConnection(
proctoringSettings.serverType,
null,
proctoringSettings.serverURL,
proctoringSettings.appKey,
@ -280,38 +287,7 @@ public class JitsiProctoringService implements ExamProctoringService {
});
}
@Override
public Result<ProctoringRoomConnection> getClientBreakOutRoomConnection(
final ProctoringServiceSettings proctoringSettings,
final String connectionToken,
final String roomName,
final String subject) {
return Result.tryCatch(() -> {
final long expTime = forExam(proctoringSettings);
final ClientConnectionData connectionData = this.examSessionService
.getConnectionData(connectionToken)
.getOrThrow();
return createProctoringConnection(
proctoringSettings.serverType,
connectionToken,
proctoringSettings.serverURL,
proctoringSettings.appKey,
proctoringSettings.getAppSecret(),
connectionData.clientConnection.userSessionId,
SEB_CLIENT_KEY,
roomName,
subject,
expTime,
false)
.getOrThrow();
});
}
protected Result<ProctoringRoomConnection> createProctoringConnection(
final ProctoringServerType proctoringServerType,
final String connectionToken,
final String url,
final String appKey,
@ -341,13 +317,14 @@ public class JitsiProctoringService implements ExamProctoringService {
moderator);
return new ProctoringRoomConnection(
proctoringServerType,
ProctoringServerType.JITSI_MEET,
connectionToken,
host,
url,
roomName,
subject,
token);
token,
clientName);
});
}

View file

@ -8,16 +8,20 @@
package ch.ethz.seb.sebserver.webservice.servicelayer.session.impl.proctoring;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.Base64;
import java.util.Base64.Encoder;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
import java.util.stream.Collectors;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import org.apache.commons.lang3.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.context.annotation.Lazy;
@ -29,8 +33,11 @@ import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestTemplate;
import org.springframework.web.util.UriComponentsBuilder;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonProperty;
import ch.ethz.seb.sebserver.ClientHttpRequestFactoryService;
import ch.ethz.seb.sebserver.gbl.Constants;
@ -41,20 +48,27 @@ import ch.ethz.seb.sebserver.gbl.api.APIMessage.FieldValidationException;
import ch.ethz.seb.sebserver.gbl.api.JSONMapper;
import ch.ethz.seb.sebserver.gbl.async.AsyncService;
import ch.ethz.seb.sebserver.gbl.async.CircuitBreaker;
import ch.ethz.seb.sebserver.gbl.client.ClientCredentials;
import ch.ethz.seb.sebserver.gbl.model.exam.Exam;
import ch.ethz.seb.sebserver.gbl.model.exam.ProctoringRoomConnection;
import ch.ethz.seb.sebserver.gbl.model.exam.ProctoringServiceSettings;
import ch.ethz.seb.sebserver.gbl.model.exam.ProctoringServiceSettings.ProctoringServerType;
import ch.ethz.seb.sebserver.gbl.model.session.ClientConnectionData;
import ch.ethz.seb.sebserver.gbl.model.session.ClientInstruction;
import ch.ethz.seb.sebserver.gbl.model.session.RemoteProctoringRoom;
import ch.ethz.seb.sebserver.gbl.profile.WebServiceProfile;
import ch.ethz.seb.sebserver.gbl.util.Cryptor;
import ch.ethz.seb.sebserver.gbl.util.Result;
import ch.ethz.seb.sebserver.gbl.util.Tuple;
import ch.ethz.seb.sebserver.gbl.util.Utils;
import ch.ethz.seb.sebserver.webservice.servicelayer.authorization.AuthorizationService;
import ch.ethz.seb.sebserver.webservice.servicelayer.dao.RemoteProctoringRoomDAO;
import ch.ethz.seb.sebserver.webservice.servicelayer.session.ExamProctoringService;
import ch.ethz.seb.sebserver.webservice.servicelayer.session.ExamSessionService;
import ch.ethz.seb.sebserver.webservice.servicelayer.session.impl.proctoring.ZoomRoomRequestResponse.CreateMeetingRequest;
import ch.ethz.seb.sebserver.webservice.servicelayer.session.impl.proctoring.ZoomRoomRequestResponse.CreateUserRequest;
import ch.ethz.seb.sebserver.webservice.servicelayer.session.impl.proctoring.ZoomRoomRequestResponse.UserPageResponse;
import ch.ethz.seb.sebserver.webservice.servicelayer.session.impl.proctoring.ZoomRoomRequestResponse.MeetingResponse;
import ch.ethz.seb.sebserver.webservice.servicelayer.session.impl.proctoring.ZoomRoomRequestResponse.UserResponse;
@Lazy
@Service
@ -67,8 +81,10 @@ public class ZoomProctoringService implements ExamProctoringService {
private static final String ZOOM_ACCESS_TOKEN_HEADER =
"{\"alg\":\"HS256\",\"typ\":\"JWT\"}";
private static final String ZOOM_ACCESS_TOKEN_PAYLOAD =
private static final String ZOOM_API_ACCESS_TOKEN_PAYLOAD =
"{\"iss\":\"%s\",\"exp\":%s}";
private static final String ZOOM_MEETING_ACCESS_TOKEN_PAYLOAD =
"{\"app_key\":\"%s\",\"iat\":%s,\"exp\":%s,\"tpc\":\"%s\",\"pwd\":\"%s\"}";
private static final Map<String, String> SEB_API_NAME_INSTRUCTION_NAME_MAPPING = Utils.immutableMapOf(Arrays.asList(
new Tuple<>(
@ -100,20 +116,26 @@ public class ZoomProctoringService implements ExamProctoringService {
private final AsyncService asyncService;
private final JSONMapper jsonMapper;
private final ZoomRestTemplate zoomRestTemplate;
private final RemoteProctoringRoomDAO remoteProctoringRoomDAO;
private final AuthorizationService authorizationService;
public ZoomProctoringService(
final ExamSessionService examSessionService,
final ClientHttpRequestFactoryService clientHttpRequestFactoryService,
final Cryptor cryptor,
final AsyncService asyncService,
final JSONMapper jsonMapper) {
final JSONMapper jsonMapper,
final RemoteProctoringRoomDAO remoteProctoringRoomDAO,
final AuthorizationService authorizationService) {
this.examSessionService = examSessionService;
this.clientHttpRequestFactoryService = clientHttpRequestFactoryService;
this.cryptor = cryptor;
this.asyncService = asyncService;
this.jsonMapper = jsonMapper;
this.zoomRestTemplate = new ZoomRestTemplate();
this.zoomRestTemplate = new ZoomRestTemplate(this);
this.remoteProctoringRoomDAO = remoteProctoringRoomDAO;
this.authorizationService = authorizationService;
}
@Override
@ -132,25 +154,32 @@ public class ZoomProctoringService implements ExamProctoringService {
try {
final ClientCredentials credentials = new ClientCredentials(
proctoringSettings.appKey,
proctoringSettings.appSecret);
final ResponseEntity<String> result = this.zoomRestTemplate
.testServiceConnection(proctoringSettings);
.testServiceConnection(
proctoringSettings.serverURL,
credentials);
if (result.getStatusCode() != HttpStatus.OK) {
throw new APIMessageException(
APIMessage.ErrorMessage.BINDING_ERROR,
String.valueOf(result.getStatusCode()));
} else {
final UserPageResponse response = this.jsonMapper.readValue(
result.getBody(),
UserPageResponse.class);
System.out.println(response);
final ResponseEntity<String> createUser = this.zoomRestTemplate
.createUser(proctoringSettings);
System.out.println(response);
}
// else {
// final UserPageResponse response = this.jsonMapper.readValue(
// result.getBody(),
// UserPageResponse.class);
//
// System.out.println(response);
//
// final ResponseEntity<String> createUser = this.zoomRestTemplate
// .createUser(credentials, "TestRoom");
//
// System.out.println(response);
// }
} catch (final Exception e) {
log.error("Failed to access Zoom service at: {}", proctoringSettings.serverURL, e);
throw new APIMessageException(APIMessage.ErrorMessage.BINDING_ERROR, e.getMessage());
@ -160,32 +189,34 @@ public class ZoomProctoringService implements ExamProctoringService {
});
}
@Override
public Result<ProctoringRoomConnection> getClientBreakOutRoomConnection(
final ProctoringServiceSettings proctoringSettings,
final String connectionToken,
final String roomName,
final String subject) {
// TODO Auto-generated method stub
return null;
}
@Override
public Result<ProctoringRoomConnection> getClientCollectingRoomConnection(
final ProctoringServiceSettings proctoringSettings,
final String connectionToken,
final String roomName,
final String subject) {
// TODO Auto-generated method stub
return null;
}
@Override
public Map<String, String> createJoinInstructionAttributes(final ProctoringRoomConnection proctoringConnection) {
// TODO Auto-generated method stub
return null;
final Map<String, String> attributes = new HashMap<>();
attributes.put(
ClientInstruction.SEB_INSTRUCTION_ATTRIBUTES.SEB_PROCTORING.SERVICE_TYPE,
ProctoringServiceSettings.ProctoringServerType.ZOOM.name());
attributes.put(
ClientInstruction.SEB_INSTRUCTION_ATTRIBUTES.SEB_PROCTORING.METHOD,
ClientInstruction.ProctoringInstructionMethod.JOIN.name());
attributes.put(
ClientInstruction.SEB_INSTRUCTION_ATTRIBUTES.SEB_PROCTORING.ZOOM_URL,
proctoringConnection.serverURL);
attributes.put(
ClientInstruction.SEB_INSTRUCTION_ATTRIBUTES.SEB_PROCTORING.ZOOM_ROOM,
proctoringConnection.roomName);
if (StringUtils.isNotBlank(proctoringConnection.subject)) {
attributes.put(
ClientInstruction.SEB_INSTRUCTION_ATTRIBUTES.SEB_PROCTORING.ZOOM_ROOM_SUBJECT,
proctoringConnection.subject);
}
attributes.put(
ClientInstruction.SEB_INSTRUCTION_ATTRIBUTES.SEB_PROCTORING.ZOOM_TOKEN,
proctoringConnection.accessToken);
attributes.put(
ClientInstruction.SEB_INSTRUCTION_ATTRIBUTES.SEB_PROCTORING.ZOOM_USER_NAME,
proctoringConnection.userName);
return attributes;
}
@Override
@ -194,33 +225,136 @@ public class ZoomProctoringService implements ExamProctoringService {
final String roomName,
final String subject) {
// TODO Auto-generated method stub
return null;
return Result.tryCatch(() -> {
final RemoteProctoringRoom remoteProctoringRoom = this.remoteProctoringRoomDAO
.getRoom(proctoringSettings.examId, roomName)
.getOrThrow();
final AdditionalZoomRoomData additionalZoomRoomData = this.jsonMapper.readValue(
remoteProctoringRoom.additionalRoomData,
AdditionalZoomRoomData.class);
final ClientCredentials credentials = new ClientCredentials(
proctoringSettings.appKey,
this.cryptor.decrypt(proctoringSettings.appSecret),
this.cryptor.decrypt(remoteProctoringRoom.joinKey));
final String jwt = this.createJWTForMeetingAccess(credentials, subject);
return new ProctoringRoomConnection(
ProctoringServerType.ZOOM,
null,
proctoringSettings.serverURL,
additionalZoomRoomData.join_url,
roomName,
subject,
jwt,
this.authorizationService.getUserService().getCurrentUser().getUsername());
});
}
@Override
public Result<ProctoringRoomConnection> getClientRoomConnection(
final ProctoringServiceSettings proctoringSettings,
final String connectionToken,
final String roomName,
final String subject) {
return Result.tryCatch(() -> {
final RemoteProctoringRoom remoteProctoringRoom = this.remoteProctoringRoomDAO
.getRoom(proctoringSettings.examId, roomName)
.getOrThrow();
final AdditionalZoomRoomData additionalZoomRoomData = this.jsonMapper.readValue(
remoteProctoringRoom.additionalRoomData,
AdditionalZoomRoomData.class);
final ClientCredentials credentials = new ClientCredentials(
proctoringSettings.appKey,
this.cryptor.decrypt(proctoringSettings.appSecret),
this.cryptor.decrypt(remoteProctoringRoom.joinKey));
final String jwt = this.createJWTForMeetingAccess(credentials, subject);
final ClientConnectionData clientConnection = this.examSessionService
.getConnectionData(connectionToken)
.getOrThrow();
return new ProctoringRoomConnection(
ProctoringServerType.ZOOM,
connectionToken,
proctoringSettings.serverURL,
additionalZoomRoomData.join_url,
roomName,
subject,
jwt,
clientConnection.clientConnection.userSessionId);
});
}
@Override
public Result<Void> disposeServiceRoomsForExam(final Exam exam) {
// TODO get all rooms of the exam
// close the rooms on Zoom service
return null;
return Result.tryCatch(() -> {
//this.remoteProctoringRoomDAO.getRoomsOfExam(exam.id);
});
// Get all rooms of the exam
}
@Override
public Result<NewRoom> newCollectingRoom(final Long roomNumber) {
// TODO create new room on zoom side and use the id as room name
return null;
public Result<NewRoom> newCollectingRoom(
final ProctoringServiceSettings proctoringSettings,
final Long roomNumber) {
return createAdHocMeeting(
UUID.randomUUID().toString(),
"Proctoring Room " + (roomNumber + 1),
proctoringSettings);
}
@Override
public Result<NewRoom> newBreakOutRoom(final String subject) {
// TODO create new room on zoom side and use the id as room name
return null;
public Result<NewRoom> newBreakOutRoom(
final ProctoringServiceSettings proctoringSettings,
final String subject) {
return createAdHocMeeting(
UUID.randomUUID().toString(),
subject,
proctoringSettings);
}
@Override
public Result<Void> disposeBreakOutRoom(final String roomName) {
// TODO close the room with specified roomName on zoom side
return null;
public Result<Void> disposeBreakOutRoom(
final ProctoringServiceSettings proctoringSettings,
final String roomName) {
return Result.tryCatch(() -> {
try {
final RemoteProctoringRoom roomData = this.remoteProctoringRoomDAO
.getRoom(proctoringSettings.examId, roomName)
.getOrThrow();
AdditionalZoomRoomData additionalZoomRoomData;
additionalZoomRoomData = this.jsonMapper.readValue(
roomData.getAdditionalRoomData(),
AdditionalZoomRoomData.class);
this.deleteAdHocMeeting(
proctoringSettings,
roomName,
additionalZoomRoomData.user_id)
.getOrThrow();
} catch (final Exception e) {
throw new RuntimeException("Unexpected error while trying to dispose ad-hoc room for zoom proctoring");
}
});
}
@Override
@ -237,82 +371,298 @@ public class ZoomProctoringService implements ExamProctoringService {
.collect(Collectors.toMap(Tuple::get_1, Tuple::get_2));
}
protected String createPayload(
final String clientKey,
final Long expTime) {
private Result<NewRoom> createAdHocMeeting(
final String roomName,
final String subject,
final ProctoringServiceSettings proctoringSettings) {
return String.format(
ZOOM_ACCESS_TOKEN_PAYLOAD.replaceAll(" ", "").replaceAll("\n", ""),
clientKey,
expTime);
return Result.tryCatch(() -> {
final ClientCredentials credentials = new ClientCredentials(
proctoringSettings.appKey,
this.cryptor.decrypt(proctoringSettings.appSecret));
// First create a new user/host for the new room
final ResponseEntity<String> createUser = this.zoomRestTemplate.createUser(
proctoringSettings.serverURL,
credentials,
roomName);
final UserResponse userResponse = this.jsonMapper.readValue(
createUser.getBody(),
UserResponse.class);
// Then create new meeting with the ad-hoc user/host
final CharSequence meetingPwd = UUID.randomUUID().toString();
final ResponseEntity<String> createMeeting = this.zoomRestTemplate.createMeeting(
roomName,
credentials,
userResponse.id,
subject,
meetingPwd);
final MeetingResponse meetingResponse = this.jsonMapper.readValue(
createMeeting.getBody(),
MeetingResponse.class);
// TODO start the meeting automatically ???
// Create NewRoom data with all needed information to store persistent
final AdditionalZoomRoomData additionalZoomRoomData = new AdditionalZoomRoomData(
userResponse.id,
meetingResponse.start_url,
meetingResponse.join_url);
final String additionalZoomRoomDataString = this.jsonMapper
.writeValueAsString(additionalZoomRoomData);
return new NewRoom(
roomName,
subject,
this.cryptor.encrypt(meetingPwd),
additionalZoomRoomDataString);
});
}
// private long forExam(final ProctoringServiceSettings examProctoring) {
// if (examProctoring.examId == null) {
// throw new IllegalStateException("Missing exam identifier from ExamProctoring data");
// }
//
// long expTime = System.currentTimeMillis() + Constants.DAY_IN_MILLIS;
// if (this.examSessionService.isExamRunning(examProctoring.examId)) {
// final Exam exam = this.examSessionService.getRunningExam(examProctoring.examId)
// .getOrThrow();
// if (exam.endTime != null) {
// expTime = exam.endTime.getMillis();
// }
// }
// return expTime;
// }
private Result<Void> deleteAdHocMeeting(
final ProctoringServiceSettings proctoringSettings,
final String meetingId,
final String userId) {
private class ZoomRestTemplate {
return Result.tryCatch(() -> {
final ClientCredentials credentials = new ClientCredentials(
proctoringSettings.appKey,
this.cryptor.decrypt(proctoringSettings.appSecret));
this.zoomRestTemplate.deleteMeeting(proctoringSettings.serverURL, credentials, meetingId);
this.zoomRestTemplate.deleteUser(proctoringSettings.serverURL, credentials, userId);
});
}
private String createJWTForAPIAccess(
final ClientCredentials credentials,
final Long expTime) {
try {
final CharSequence decryptedSecret = this.cryptor.decrypt(credentials.secret);
final StringBuilder builder = new StringBuilder();
final Encoder urlEncoder = Base64.getUrlEncoder().withoutPadding();
final String jwtHeaderPart = urlEncoder.encodeToString(
ZOOM_ACCESS_TOKEN_HEADER.getBytes(StandardCharsets.UTF_8));
final String jwtPayload = String.format(
ZOOM_API_ACCESS_TOKEN_PAYLOAD.replaceAll(" ", "").replaceAll("\n", ""),
credentials.clientIdAsString(),
expTime);
final String jwtPayloadPart = urlEncoder.encodeToString(
jwtPayload.getBytes(StandardCharsets.UTF_8));
final String message = jwtHeaderPart + "." + jwtPayloadPart;
final Mac sha256_HMAC = Mac.getInstance(TOKEN_ENCODE_ALG);
final SecretKeySpec secret_key = new SecretKeySpec(
Utils.toByteArray(decryptedSecret),
TOKEN_ENCODE_ALG);
sha256_HMAC.init(secret_key);
final String hash = urlEncoder.encodeToString(
sha256_HMAC.doFinal(Utils.toByteArray(message)));
builder.append(message)
.append(".")
.append(hash);
return builder.toString();
} catch (final Exception e) {
throw new RuntimeException("Failed to create JWT for Zoom API access: ", e);
}
}
private String createJWTForMeetingAccess(
final ClientCredentials credentials,
final String subject) {
try {
final long iat = Utils.getMillisecondsNow() / 1000;
final long exp = iat + 7200;
final CharSequence decryptedSecret = this.cryptor.decrypt(credentials.secret);
final StringBuilder builder = new StringBuilder();
final Encoder urlEncoder = Base64.getUrlEncoder().withoutPadding();
final String jwtHeaderPart = urlEncoder.encodeToString(
ZOOM_ACCESS_TOKEN_HEADER.getBytes(StandardCharsets.UTF_8));
final String jwtPayload = String.format(
ZOOM_API_ACCESS_TOKEN_PAYLOAD.replaceAll(" ", "").replaceAll("\n", ""),
credentials.clientIdAsString(),
iat,
exp,
subject,
credentials.accessTokenAsString());
final String jwtPayloadPart = urlEncoder.encodeToString(
jwtPayload.getBytes(StandardCharsets.UTF_8));
final String message = jwtHeaderPart + "." + jwtPayloadPart;
final Mac sha256_HMAC = Mac.getInstance(TOKEN_ENCODE_ALG);
final SecretKeySpec secret_key = new SecretKeySpec(
Utils.toByteArray(decryptedSecret),
TOKEN_ENCODE_ALG);
sha256_HMAC.init(secret_key);
final String hash = urlEncoder.encodeToString(
sha256_HMAC.doFinal(Utils.toByteArray(message)));
builder.append(message)
.append(".")
.append(hash);
return builder.toString();
} catch (final Exception e) {
throw new RuntimeException("Failed to create JWT for Zoom meeting access: ", e);
}
}
private final static class ZoomRestTemplate {
private static final String API_TEST_ENDPOINT =
"v2/users?status=active&page_size=30&page_number=1&data_type=Json";
private static final String API_CREATE_USER_ENDPOINT = "v2/users";
private static final String API_DELETE_USER_ENDPOINT = "v2/users/{userid}?action=delete";
private static final String API_USER_CUST_CREATE = "custCreate";
private static final String API_ZOOM_ROOM_USER = "ZoomRoomUser";
private static final String API_CREATE_MEETING_ENDPOINT = "v2/users/{userid}/meetings";
private static final String API_DELETE_MEETING_ENDPOINT = "v2/meetings/{meetingid}";
private final ZoomProctoringService zoomProctoringService;
private final RestTemplate restTemplate;
private final CircuitBreaker<ResponseEntity<String>> circuitBreaker;
public ZoomRestTemplate() {
public ZoomRestTemplate(final ZoomProctoringService zoomProctoringService) {
this.restTemplate = new RestTemplate(ZoomProctoringService.this.clientHttpRequestFactoryService
this.zoomProctoringService = zoomProctoringService;
this.restTemplate = new RestTemplate(zoomProctoringService.clientHttpRequestFactoryService
.getClientHttpRequestFactory()
.getOrThrow());
this.circuitBreaker = ZoomProctoringService.this.asyncService.createCircuitBreaker(
this.circuitBreaker = zoomProctoringService.asyncService.createCircuitBreaker(
2,
10 * Constants.SECOND_IN_MILLIS,
10 * Constants.SECOND_IN_MILLIS);
}
public ResponseEntity<String> testServiceConnection(final ProctoringServiceSettings proctoringSettings) {
final String url = proctoringSettings.serverURL.endsWith(Constants.SLASH.toString())
? proctoringSettings.serverURL + API_TEST_ENDPOINT
: proctoringSettings.serverURL + "/" + API_TEST_ENDPOINT;
return exchange(url, HttpMethod.GET, proctoringSettings);
public ResponseEntity<String> testServiceConnection(
final String zoomServerUrl,
final ClientCredentials credentials) {
final String url = UriComponentsBuilder
.fromUriString(zoomServerUrl)
.path(API_TEST_ENDPOINT)
.toString();
return exchange(url, HttpMethod.GET, credentials);
}
public ResponseEntity<String> createUser(final ProctoringServiceSettings proctoringSettings)
throws JsonProcessingException {
final String url = proctoringSettings.serverURL.endsWith(Constants.SLASH.toString())
? proctoringSettings.serverURL + "v2/users"
: proctoringSettings.serverURL + "/" + "v2/users";
final CreateUserRequest createUserRequest = new CreateUserRequest(
"custCreate",
new CreateUserRequest.UserInfo(
"andreas.hefti@let.ethz.ch",
1,
"Andreas",
"Hefti"));
final String body = ZoomProctoringService.this.jsonMapper.writeValueAsString(createUserRequest);
final HttpHeaders headers = getHeaders(proctoringSettings);
headers.set(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE);
return exchange(url, HttpMethod.POST, body, headers, null);
public ResponseEntity<String> createUser(
final String zoomServerUrl,
final ClientCredentials credentials,
final String roomName) {
try {
final String url = UriComponentsBuilder
.fromUriString(zoomServerUrl)
.path(API_CREATE_USER_ENDPOINT)
.toString();
final String host = new URL(zoomServerUrl).getHost();
final CreateUserRequest createUserRequest = new CreateUserRequest(
API_USER_CUST_CREATE,
new CreateUserRequest.UserInfo(
roomName + "@" + host,
1,
roomName,
API_ZOOM_ROOM_USER));
final String body = this.zoomProctoringService.jsonMapper.writeValueAsString(createUserRequest);
final HttpHeaders headers = getHeaders(credentials);
headers.set(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE);
return exchange(url, HttpMethod.POST, body, headers);
} catch (final Exception e) {
log.error("Failed to create Zoom ad-hoc user for room: {}", roomName, e);
throw new RuntimeException("Failed to create Zoom ad-hoc user", e);
}
}
private HttpHeaders getHeaders(final ProctoringServiceSettings proctoringSettings) {
final String jwt = createJWT(
proctoringSettings.appKey,
proctoringSettings.appSecret,
System.currentTimeMillis() + Constants.MINUTE_IN_MILLIS);
public ResponseEntity<String> createMeeting(
final String zoomServerUrl,
final ClientCredentials credentials,
final String userId,
final String topic,
final CharSequence password) {
try {
final String url = UriComponentsBuilder
.fromUriString(zoomServerUrl)
.path(API_CREATE_MEETING_ENDPOINT)
.buildAndExpand(userId)
.toString();
final CreateMeetingRequest createRoomRequest = new CreateMeetingRequest(topic, password);
final String body = this.zoomProctoringService.jsonMapper.writeValueAsString(createRoomRequest);
final HttpHeaders headers = getHeaders(credentials);
headers.set(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE);
return exchange(url, HttpMethod.POST, body, headers);
} catch (final Exception e) {
log.error("Failed to create Zoom ad-hoc meeting: {}", topic, e);
throw new RuntimeException("Failed to create Zoom ad-hoc meeting", e);
}
}
public ResponseEntity<String> deleteMeeting(
final String zoomServerUrl,
final ClientCredentials credentials,
final String meetingId) {
try {
final String url = UriComponentsBuilder
.fromUriString(zoomServerUrl)
.path(API_DELETE_MEETING_ENDPOINT)
.buildAndExpand(meetingId)
.toString();
return exchange(url, HttpMethod.DELETE, credentials);
} catch (final Exception e) {
log.error("Failed to delete Zoom ad-hoc meeting: {}", meetingId, e);
throw new RuntimeException("Failed to delete Zoom ad-hoc meeting", e);
}
}
public ResponseEntity<String> deleteUser(
final String zoomServerUrl,
final ClientCredentials credentials,
final String userId) {
try {
final String url = UriComponentsBuilder
.fromUriString(zoomServerUrl)
.path(API_DELETE_USER_ENDPOINT)
.buildAndExpand(userId)
.toString();
return exchange(url, HttpMethod.DELETE, credentials);
} catch (final Exception e) {
log.error("Failed to delete Zoom ad-hoc user with id: {}", userId, e);
throw new RuntimeException("Failed to delete Zoom ad-hoc user", e);
}
}
private HttpHeaders getHeaders(final ClientCredentials credentials) {
final String jwt = this.zoomProctoringService
.createJWTForAPIAccess(
credentials,
System.currentTimeMillis() + Constants.MINUTE_IN_MILLIS);
final HttpHeaders httpHeaders = new HttpHeaders();
httpHeaders.set(HttpHeaders.AUTHORIZATION, "Bearer " + jwt);
@ -323,38 +673,31 @@ public class ZoomProctoringService implements ExamProctoringService {
private ResponseEntity<String> exchange(
final String url,
final HttpMethod method,
final ProctoringServiceSettings proctoringSettings) {
final ClientCredentials credentials) {
return exchange(url, HttpMethod.GET, null, getHeaders(proctoringSettings), null);
return exchange(url, HttpMethod.GET, null, getHeaders(credentials));
}
private ResponseEntity<String> exchange(
final String url,
final HttpMethod method,
final Object body,
final HttpHeaders httpHeaders,
final Map<String, ?> uriVariables) {
final HttpHeaders httpHeaders) {
final Result<ResponseEntity<String>> protectedRunResult = this.circuitBreaker.protectedRun(() -> {
final HttpEntity<Object> httpEntity = (body != null)
? new HttpEntity<>(body, httpHeaders)
: new HttpEntity<>(httpHeaders);
final ResponseEntity<String> result = (uriVariables != null)
? this.restTemplate.exchange(
url,
method,
httpEntity,
String.class,
uriVariables)
: this.restTemplate.exchange(
url,
method,
httpEntity,
String.class);
final ResponseEntity<String> result = this.restTemplate.exchange(
url,
method,
httpEntity,
String.class);
if (result.getStatusCode() != HttpStatus.OK) {
log.warn("Zoom API call to {} respond not 200 -> {}", url, result.getStatusCode());
throw new RuntimeException("Error Response: " + result.getStatusCode());
}
return result;
@ -362,39 +705,29 @@ public class ZoomProctoringService implements ExamProctoringService {
return protectedRunResult.getOrThrow();
}
private String createJWT(
final String appKey,
final CharSequence appSecret,
final Long expTime) {
}
try {
final StringBuilder builder = new StringBuilder();
final Encoder urlEncoder = Base64.getUrlEncoder().withoutPadding();
@JsonIgnoreProperties(ignoreUnknown = true)
public static final class AdditionalZoomRoomData {
final String jwtHeaderPart = urlEncoder.encodeToString(
ZOOM_ACCESS_TOKEN_HEADER.getBytes(StandardCharsets.UTF_8));
final String jwtPayload = createPayload(appKey, expTime);
final String jwtPayloadPart = urlEncoder.encodeToString(
jwtPayload.getBytes(StandardCharsets.UTF_8));
final String message = jwtHeaderPart + "." + jwtPayloadPart;
@JsonProperty("user_id")
public final String user_id;
@JsonProperty("start_url")
public final String start_url;
@JsonProperty("join_url")
public final String join_url;
final Mac sha256_HMAC = Mac.getInstance(TOKEN_ENCODE_ALG);
final SecretKeySpec secret_key = new SecretKeySpec(
Utils.toByteArray(appSecret),
TOKEN_ENCODE_ALG);
sha256_HMAC.init(secret_key);
final String hash = urlEncoder.encodeToString(
sha256_HMAC.doFinal(Utils.toByteArray(message)));
@JsonCreator
public AdditionalZoomRoomData(
@JsonProperty("user_id") final String user_id,
@JsonProperty("start_url") final String start_url,
@JsonProperty("join_url") final String join_url) {
builder.append(message)
.append(".")
.append(hash);
return builder.toString();
} catch (final Exception e) {
throw new RuntimeException("Failed to create JWT for Zoom API access: ", e);
}
this.user_id = user_id;
this.start_url = start_url;
this.join_url = join_url;
}
}
}

View file

@ -88,14 +88,14 @@ public interface ZoomRoomRequestResponse {
}
@JsonIgnoreProperties(ignoreUnknown = true)
static class CreateUserResponse {
static class UserResponse {
final String id;
final String email;
final int type;
final String first_name;
final String lasr_name;
@JsonCreator
public CreateUserResponse(
public UserResponse(
@JsonProperty("id") final String id,
@JsonProperty("email") final String email,
@JsonProperty("type") final int type,
@ -111,34 +111,34 @@ public interface ZoomRoomRequestResponse {
// https://marketplace.zoom.us/docs/api-reference/zoom-api/meetings/meetingcreate
@JsonIgnoreProperties(ignoreUnknown = true)
static class NewRoomRequest {
static class CreateMeetingRequest {
@JsonProperty final String topic;
@JsonProperty final int type = 1; // Instant meeting
@JsonProperty final String start_time = DateTime.now(DateTimeZone.UTC).toString("yyyy-MM-dd`T`HH:mm:ssZ");
@JsonProperty final String password;
@JsonProperty final CharSequence password;
@JsonProperty final Settings settings;
public NewRoomRequest(final String topic, final String password, final Settings settings) {
public CreateMeetingRequest(final String topic, final CharSequence password) {
this.topic = topic;
this.password = password;
this.settings = settings;
this.settings = new Settings();
}
@JsonIgnoreProperties(ignoreUnknown = true)
static class Settings {
final boolean host_video = false;
final boolean participant_video = true;
final boolean join_before_host = true;
final int jbh_time = 0;
final boolean use_pmi = false;
final String audio = "voip";
@JsonProperty final boolean host_video = false;
@JsonProperty final boolean participant_video = true;
@JsonProperty final boolean join_before_host = true;
@JsonProperty final int jbh_time = 0;
@JsonProperty final boolean use_pmi = false;
@JsonProperty final String audio = "voip";
}
}
// https://marketplace.zoom.us/docs/api-reference/zoom-api/meetings/meetingcreate
@JsonIgnoreProperties(ignoreUnknown = true)
static class NewRoomResponse {
final Integer id;
static class MeetingResponse {
final Long id;
final String join_url;
final String start_url;
final String start_time;
@ -148,8 +148,8 @@ public interface ZoomRoomRequestResponse {
final String host_id;
@JsonCreator
public NewRoomResponse(
@JsonProperty("id") final Integer id,
public MeetingResponse(
@JsonProperty("id") final Long id,
@JsonProperty("join_url") final String join_url,
@JsonProperty("start_url") final String start_url,
@JsonProperty("start_time") final String start_time,

View file

@ -9,6 +9,6 @@ ADD COLUMN IF NOT EXISTS `vdi_pair_token` VARCHAR(255) NULL AFTER `vdi`;
-- Alter Table `remote_proctoring_room`
-- -----------------------------------------------------
ALTER TABLE `remote_proctoring_room`
ADD COLUMN IF NOT EXISTS `break_out_connections` VARCHAR(10000) NULL,
ADD COLUMN IF NOT EXISTS `break_out_connections` VARCHAR(4000) NULL,
ADD COLUMN IF NOT EXISTS `join_key` VARCHAR(255) NULL,
ADD COLUMN IF NOT EXISTS `room_data` VARCHAR(10000) NULL;
ADD COLUMN IF NOT EXISTS `room_data` VARCHAR(4000) NULL;

View file

@ -18,7 +18,6 @@ import org.junit.Test;
import org.mockito.Mockito;
import ch.ethz.seb.sebserver.gbl.model.exam.ProctoringRoomConnection;
import ch.ethz.seb.sebserver.gbl.model.exam.ProctoringServiceSettings.ProctoringServerType;
import ch.ethz.seb.sebserver.gbl.util.Cryptor;
public class ExamJITSIProctoringServiceTest {
@ -64,7 +63,6 @@ public class ExamJITSIProctoringServiceTest {
final JitsiProctoringService examJITSIProctoringService =
new JitsiProctoringService(null, null, cryptorMock, null);
final ProctoringRoomConnection data = examJITSIProctoringService.createProctoringConnection(
ProctoringServerType.JITSI_MEET,
"connectionToken",
"https://seb-jitsi.example.ch",
"test-app",

View file

@ -95,9 +95,9 @@ CREATE TABLE IF NOT EXISTS `remote_proctoring_room` (
`size` INT NULL,
`subject` VARCHAR(255) NULL,
`townhall_room` INT(1) NOT NULL DEFAULT 0,
`break_out_connections` VARCHAR(10000) NULL,
`break_out_connections` VARCHAR(4000) NULL,
`join_key` VARCHAR(255) NULL,
`room_data` VARCHAR(10000) NULL,
`room_data` VARCHAR(4000) NULL,
PRIMARY KEY (`id`),
INDEX `proctor_room_exam_id_idx` (`exam_id` ASC),
CONSTRAINT `proctorRoomExamRef`