SEBSERV-418

This commit is contained in:
anhefti 2024-05-22 13:32:30 +02:00
parent 1ae00cc4ab
commit 9618b942fb
15 changed files with 613 additions and 135 deletions

View file

@ -348,13 +348,18 @@
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-text</artifactId>
<version>1.8</version>
<version>1.10.0</version>
</dependency>
<dependency>
<groupId>com.github.vladimir-bukhtoyarov</groupId>
<artifactId>bucket4j-core</artifactId>
<version>4.10.0</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>0.9.1</version>
</dependency>
<!-- Testing -->

View file

@ -64,8 +64,11 @@ public final class API {
public static final String OAUTH_ENDPOINT = "/oauth";
public static final String OAUTH_TOKEN_ENDPOINT = OAUTH_ENDPOINT + "/token";
public static final String OAUTH_JWTTOKEN_ENDPOINT = OAUTH_ENDPOINT + "/jwttoken";
public static final String OAUTH_JWT_TOKEN_ENDPOINT = OAUTH_ENDPOINT + "/jwttoken";
public static final String OAUTH_JWT_TOKEN_VERIFY_ENDPOINT = OAUTH_JWT_TOKEN_ENDPOINT + "/verify";
public static final String OAUTH_REVOKE_TOKEN_ENDPOINT = OAUTH_ENDPOINT + "/revoke-token";
public static final String SPS_OAUTH_JWT_TOKEN_ENDPOINT = OAUTH_ENDPOINT + "/jwttoken";
public static final String GRANT_TYPE_PASSWORD = "password";
public static final String GRANT_TYPE_CLIENT = "client_credentials";
@ -175,6 +178,8 @@ public final class API {
public static final String LMS_FULL_INTEGRATION_EXAM_TEMPLATE_ID = "exam_template_id";
public static final String LMS_FULL_INTEGRATION_QUIT_PASSWORD = "quit_password";
public static final String LMS_FULL_INTEGRATION_QUIT_LINK = "quit_link";
public static final String LMS_FULL_INTEGRATION_USER_ID = "user_id";
public static final String LMS_FULL_INTEGRATION_USER_NAME = "user_name";
public static final String LMS_FULL_INTEGRATION_TIME_ZONE = "account_time_zone";
public static final String USER_ACCOUNT_ENDPOINT = "/useraccount";

View file

@ -0,0 +1,37 @@
/*
* Copyright (c) 2019 ETH Zürich, IT Services
*
* 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.gbl.model.user;
import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonProperty;
import org.springframework.security.oauth2.common.OAuth2AccessToken;
public class TokenLoginInfo {
@JsonProperty
public final String username;
@JsonProperty
public final String userUUID;
@JsonProperty
public final String redirect;
@JsonProperty
public final OAuth2AccessToken login;
@JsonCreator
public TokenLoginInfo(
@JsonProperty final String username,
@JsonProperty final String userUUID,
@JsonProperty final String redirect,
@JsonProperty final OAuth2AccessToken login) {
this.username = username;
this.userUUID = userUUID;
this.redirect = redirect;
this.login = login;
}
}

View file

@ -49,6 +49,10 @@ public class Cryptor {
this.internalPWD = environment.getProperty("sebserver.webservice.internalSecret");
}
public CharSequence getInternalPWD() {
return this.internalPWD;
}
/** Use this to encrypt a text with the internal password
*
* @param text The text to encrypt with the internal password

View file

@ -330,7 +330,7 @@ public class MonitoringProctoringService {
String.class);
// JWT token request URL
final String jwtTokenURL = settings.spsServiceURL + API.OAUTH_JWTTOKEN_ENDPOINT;
final String jwtTokenURL = settings.spsServiceURL + API.SPS_OAUTH_JWT_TOKEN_ENDPOINT;
// Basic Auth header and content type header
final HttpHeaders httpHeaders = new HttpHeaders();

View file

@ -0,0 +1,36 @@
/*
* Copyright (c) 2019 ETH Zürich, IT Services
*
* 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.authorization;
import ch.ethz.seb.sebserver.gbl.model.exam.Exam;
import ch.ethz.seb.sebserver.gbl.model.user.TokenLoginInfo;
import ch.ethz.seb.sebserver.gbl.model.user.UserInfo;
import ch.ethz.seb.sebserver.gbl.util.Result;
public interface TeacherAccountService {
Result<UserInfo> createNewTeacherAccountForExam(
Exam exam,
String userId,
String username,
String timezone);
Result<Exam> deleteTeacherAccountsForExam(final Exam exam);
Result<String> getOneTimeTokenForTeacherAccount(
Exam exam,
String userId,
String username,
String timezone,
final boolean createIfNotExists);
Result<TokenLoginInfo> verifyOneTimeTokenForTeacherAccount(String token);
}

View file

@ -0,0 +1,323 @@
/*
* Copyright (c) 2019 ETH Zürich, IT Services
*
* 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.authorization.impl;
import java.util.*;
import ch.ethz.seb.sebserver.gbl.Constants;
import ch.ethz.seb.sebserver.gbl.api.APIMessage;
import ch.ethz.seb.sebserver.gbl.api.EntityType;
import ch.ethz.seb.sebserver.gbl.model.Domain;
import ch.ethz.seb.sebserver.gbl.model.EntityKey;
import ch.ethz.seb.sebserver.gbl.model.exam.Exam;
import ch.ethz.seb.sebserver.gbl.model.user.TokenLoginInfo;
import ch.ethz.seb.sebserver.gbl.model.user.UserInfo;
import ch.ethz.seb.sebserver.gbl.model.user.UserMod;
import ch.ethz.seb.sebserver.gbl.model.user.UserRole;
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.Utils;
import ch.ethz.seb.sebserver.webservice.datalayer.batis.model.AdditionalAttributeRecord;
import ch.ethz.seb.sebserver.webservice.servicelayer.authorization.TeacherAccountService;
import ch.ethz.seb.sebserver.webservice.servicelayer.dao.AdditionalAttributesDAO;
import ch.ethz.seb.sebserver.webservice.servicelayer.dao.ExamDAO;
import ch.ethz.seb.sebserver.webservice.servicelayer.dao.FilterMap;
import ch.ethz.seb.sebserver.webservice.servicelayer.dao.UserDAO;
import ch.ethz.seb.sebserver.webservice.servicelayer.session.ScreenProctoringService;
import ch.ethz.seb.sebserver.webservice.weblayer.oauth.AdminAPIClientDetails;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import org.apache.commons.lang3.StringUtils;
import org.joda.time.DateTimeZone;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.context.annotation.Lazy;
import org.springframework.http.ResponseEntity;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.oauth2.common.OAuth2AccessToken;
import org.springframework.security.oauth2.provider.endpoint.TokenEndpoint;
import org.springframework.security.web.authentication.WebAuthenticationDetails;
import org.springframework.stereotype.Service;
@Lazy
@Service
@WebServiceProfile
public class TeacherAccountServiceImpl implements TeacherAccountService {
private static final Logger log = LoggerFactory.getLogger(TeacherAccountServiceImpl.class);
private static final String SUBJECT_CLAIM_NAME = "sub";
private static final String USER_CLAIM = "usr";
private static final String EXAM_ID_CLAIM = "exam";
private static final String EXAM_OTT_SUBJECT_PREFIX = "EXAM_OTT_SUBJECT_";
private final UserDAO userDAO;
private final ScreenProctoringService screenProctoringService;
private final ExamDAO examDAO;
private final Cryptor cryptor;
private final AdditionalAttributesDAO additionalAttributesDAO;
final TokenEndpoint tokenEndpoint;
private final AdminAPIClientDetails adminAPIClientDetails;
public TeacherAccountServiceImpl(
final UserDAO userDAO,
final ScreenProctoringService screenProctoringService,
final ExamDAO examDAO,
final Cryptor cryptor,
final AdditionalAttributesDAO additionalAttributesDAO,
final TokenEndpoint tokenEndpoint,
final AdminAPIClientDetails adminAPIClientDetails) {
this.userDAO = userDAO;
this.screenProctoringService = screenProctoringService;
this.examDAO = examDAO;
this.cryptor = cryptor;
this.additionalAttributesDAO = additionalAttributesDAO;
this.tokenEndpoint = tokenEndpoint;
this.adminAPIClientDetails = adminAPIClientDetails;
}
@Override
public Result<UserInfo> createNewTeacherAccountForExam(
final Exam exam,
final String userId,
final String username,
final String timezone) {
return Result.tryCatch(() -> {
final String uuid = UUID.randomUUID().toString();
DateTimeZone dtz = DateTimeZone.UTC;
if (StringUtils.isNotBlank(timezone)) {
try {
dtz = DateTimeZone.forID(timezone);
} catch (final Exception e) {
log.warn("Failed to set requested time zone for ad-hoc teacher account: {}", timezone);
}
}
final UserMod adHocTeacherUser = new UserMod(
uuid,
exam.institutionId,
userId,
getTeacherAccountIdentifier(exam),
username,
uuid,
uuid,
null,
Locale.ENGLISH,
dtz,
true,
false,
Utils.immutableSetOf(UserRole.TEACHER.name()));
return userDAO.createNew(adHocTeacherUser)
.flatMap(account -> userDAO.setActive(account, true))
.getOrThrow();
});
}
@Override
public Result<Exam> deleteTeacherAccountsForExam(final Exam exam) {
return Result.tryCatch(() -> {
final String externalId = exam.externalId;
final FilterMap filter = new FilterMap();
filter.putIfAbsent(Domain.USER.ATTR_SURNAME, getTeacherAccountIdentifier(exam));
final Collection<UserInfo> accounts = userDAO.allMatching(filter).getOrThrow();
if (accounts.isEmpty()) {
return exam;
}
if (accounts.size() > 1) {
log.error("Too many accounts found!?... ad-hoc teacher account mapping: {}", externalId);
return exam;
}
userDAO.delete(Utils.immutableSetOf(new EntityKey(
accounts.iterator().next().uuid,
EntityType.USER)))
.getOrThrow();
return exam;
});
}
@Override
public Result<String> getOneTimeTokenForTeacherAccount(
final Exam exam,
final String userId,
final String username,
final String timezone,
final boolean createIfNotExists) {
return this.userDAO
.byModelId(userId)
.onErrorDo(error -> handleAccountDoesNotExistYet(createIfNotExists, exam, userId, username, timezone))
.map(account -> applySupporter(account, exam))
.map(account -> {
this.screenProctoringService.synchronizeSPSUserForExam(exam.id);
return account;
})
.map(account -> this.createOneTimeToken(account, exam.id));
}
@Override
public Result<TokenLoginInfo> verifyOneTimeTokenForTeacherAccount(final String loginToken) {
return Result.tryCatch(() -> {
final Claims claims = checkJWTValid(loginToken);
final String userId = claims.get(USER_CLAIM, String.class);
// check if requested user exists
final UserInfo user = this.userDAO
.byModelId(userId)
.getOrThrow(error -> new BadCredentialsException("Unknown user claim", error));
// login the user by getting access token
final Map<String, String> params = new HashMap<>();
params.put("grant_type", "password");
params.put("username", user.username);
params.put("password", user.uuid);
//final WebAuthenticationDetails details = new WebAuthenticationDetails("localhost", null);
final UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken =
new UsernamePasswordAuthenticationToken(
this.adminAPIClientDetails, // TODO are this the correct details?
null,
Collections.emptyList());
final ResponseEntity<OAuth2AccessToken> accessToken =
this.tokenEndpoint.postAccessToken(usernamePasswordAuthenticationToken, params);
final OAuth2AccessToken token = accessToken.getBody();
return new TokenLoginInfo(user.username, user.uuid, null, token);
});
}
private UserInfo handleAccountDoesNotExistYet(
final boolean createIfNotExists,
final Exam exam,
final String userId,
final String username,
final String timezone) {
if (createIfNotExists) {
return this
.createNewTeacherAccountForExam(exam, userId, username, timezone)
.getOrThrow();
} else {
throw new RuntimeException("Teacher Account with userId "+ userId + " and username "+username+" does not exist.");
}
}
private UserInfo applySupporter(final UserInfo account, final Exam exam) {
if (!exam.supporter.contains(account.uuid)) {
this.examDAO.applySupporter(exam, account.uuid)
.onError(error -> log.error(
"Failed to apply ad-hoc-teacher account to supporter list of exam: {} user: {}",
exam, account, error));
}
return account;
}
private String createOneTimeToken(final UserInfo account, final Long examId) {
// create a subject claim for this token only
final String subjectClaim = UUID.randomUUID().toString();
this.storeSubjectForExam(examId, account.uuid, subjectClaim);
final Map<String, Object> claims = new HashMap<>();
claims.put(USER_CLAIM, account.uuid);
claims.put(EXAM_ID_CLAIM, String.valueOf(examId));
return createToken(claims, subjectClaim);
}
// NOTE Token is expired in 30 seconds and is signed with internal secret
private String createToken(final Map<String, Object> claims, final String subject) {
final long millisecondsNow = Utils.getMillisecondsNow();
return Jwts.builder()
.setClaims(claims)
.setSubject(subject)
.setIssuedAt(new Date(millisecondsNow))
.setExpiration(new Date(millisecondsNow + 30 * Constants.SECOND_IN_MILLIS))
.signWith(SignatureAlgorithm.HS256, this.cryptor.getInternalPWD().toString())
.compact();
}
private Claims checkJWTValid(final String loginToken) {
// decode given JWT
final Claims claims = Jwts.parser()
.setSigningKey(this.cryptor.getInternalPWD().toString())
.parseClaimsJws(loginToken)
.getBody();
// check expiration date
final long expirationTime = claims.getExpiration().getTime();
final long now = Utils.getMillisecondsNow();
if (expirationTime < now) {
throw new APIMessage.APIMessageException(APIMessage.ErrorMessage.UNAUTHORIZED.of("Token expired"));
}
// check user claim
final String userId = claims.get(USER_CLAIM, String.class);
if (StringUtils.isBlank(userId)) {
throw new APIMessage.APIMessageException(APIMessage.ErrorMessage.UNAUTHORIZED.of("User not found"));
}
// get exam id
final String examId = claims.get(EXAM_ID_CLAIM, String.class);
if (StringUtils.isBlank(examId)) {
throw new APIMessage.APIMessageException(APIMessage.ErrorMessage.UNAUTHORIZED.of("Exam id not found"));
}
final Long examPK = Long.parseLong(examId);
// check subject
final String subjectClaim = getSubjectForExam(examPK, userId);
if (StringUtils.isBlank(subjectClaim)) {
throw new APIMessage.APIMessageException(APIMessage.ErrorMessage.UNAUTHORIZED.of("Subject not found"));
}
final String subject = claims.get(SUBJECT_CLAIM_NAME, String.class);
if (!subjectClaim.equals(subject)) {
throw new APIMessage.APIMessageException(APIMessage.ErrorMessage.UNAUTHORIZED.of("Token subject mismatch"));
}
return claims;
}
private void storeSubjectForExam(final Long examId, final String userId, final String subject) {
additionalAttributesDAO.saveAdditionalAttribute(
EntityType.EXAM,
examId,
EXAM_OTT_SUBJECT_PREFIX + userId,
subject)
.getOrThrow();
}
private void deleteSubjectForExam(final Long examId, final String userId) {
additionalAttributesDAO.delete(EntityType.EXAM, examId, EXAM_OTT_SUBJECT_PREFIX + userId);
}
private String getSubjectForExam(final Long examId, final String userId) {
return additionalAttributesDAO
.getAdditionalAttribute(EntityType.EXAM, examId, EXAM_OTT_SUBJECT_PREFIX + userId)
.map(AdditionalAttributeRecord::getValue)
.onError(error -> log.warn("Failed to get OTT subject from exam: {}", error.getMessage()))
.getOrElse(null);
}
private static String getTeacherAccountIdentifier(final Exam exam) {
return "AdHoc-Teacher-Account-" + exam.id;
}
}

View file

@ -228,6 +228,11 @@ public interface ExamDAO extends ActivatableEntityDAO<Exam, Exam>, BulkActionSup
key = "#examId")
void markUpdate(Long examId);
@CacheEvict(
cacheNames = ExamSessionCacheService.CACHE_NAME_RUNNING_EXAM,
key = "#examId")
Result<Exam> applySupporter(Exam exam, String userUUID);
/** This is used by the internal update process to mark exams for which the LMS related data availability
*
* @param externalQuizId The exams external UUID or quiz id of the exam to mark
@ -238,4 +243,5 @@ public interface ExamDAO extends ActivatableEntityDAO<Exam, Exam>, BulkActionSup
void updateQuitPassword(Exam exam, String quitPassword);
}

View file

@ -12,6 +12,7 @@ import static ch.ethz.seb.sebserver.webservice.datalayer.batis.mapper.ExamRecord
import static ch.ethz.seb.sebserver.webservice.datalayer.batis.mapper.ExamRecordDynamicSqlSupport.examRecord;
import static org.mybatis.dynamic.sql.SqlBuilder.*;
import java.sql.Array;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
@ -189,8 +190,7 @@ public class ExamDAOImpl implements ExamDAO {
UpdateDSL.updateWithMapper(
this.examRecordMapper::update,
ExamRecordDynamicSqlSupport.examRecord)
.set(ExamRecordDynamicSqlSupport.lastModified)
.equalTo(millisecondsNow)
.set(ExamRecordDynamicSqlSupport.lastModified).equalTo(millisecondsNow)
.where(ExamRecordDynamicSqlSupport.id, isEqualTo(examId))
.build()
.execute();
@ -200,6 +200,30 @@ public class ExamDAOImpl implements ExamDAO {
}
}
@Override
@Transactional
public Result<Exam> applySupporter(final Exam exam, final String userUUID) {
return Result.tryCatch(() -> {
final long millisecondsNow = Utils.getMillisecondsNow();
final List<String> newSupporter = new ArrayList<>(exam.supporter);
newSupporter.add(userUUID);
UpdateDSL.updateWithMapper(
this.examRecordMapper::update,
ExamRecordDynamicSqlSupport.examRecord)
.set(supporter).equalTo(StringUtils.join(newSupporter, Constants.LIST_SEPARATOR_CHAR))
.set(ExamRecordDynamicSqlSupport.lastModified).equalTo(millisecondsNow)
.where(ExamRecordDynamicSqlSupport.id, isEqualTo(exam.id))
.build()
.execute();
return examRecordMapper.selectByPrimaryKey(exam.id);
})
.flatMap(this::toDomainModel)
.onError(TransactionHandler::rollback);
}
@Override
public void markLMSAvailability(final String externalQuizId, final boolean available, final String updateId) {

View file

@ -10,7 +10,6 @@ package ch.ethz.seb.sebserver.webservice.servicelayer.lms;
import java.io.OutputStream;
import java.util.Collection;
import java.util.Map;
import ch.ethz.seb.sebserver.gbl.model.EntityKey;
import ch.ethz.seb.sebserver.gbl.model.exam.Exam;
@ -42,8 +41,7 @@ public interface FullLmsIntegrationService {
String quizId,
String examTemplateId,
String quitPassword,
String quitLink,
String timezone);
String quitLink);
Result<EntityKey> deleteExam(
String lmsUUID,
@ -59,6 +57,14 @@ public interface FullLmsIntegrationService {
String quizId,
OutputStream out);
Result<String> getOneTimeLoginToken(
String lmsUUId,
String courseId,
String quizId,
String userId,
String username,
String timezone);
@JsonIgnoreProperties(ignoreUnknown = true)
final class IntegrationData {
@JsonProperty("id")

View file

@ -12,15 +12,12 @@ import java.io.OutputStream;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.Collection;
import java.util.Locale;
import java.util.UUID;
import java.util.function.Function;
import java.util.stream.Collectors;
import ch.ethz.seb.sebserver.ClientHttpRequestFactoryService;
import ch.ethz.seb.sebserver.gbl.api.API;
import ch.ethz.seb.sebserver.gbl.api.APIMessage;
import ch.ethz.seb.sebserver.gbl.api.EntityType;
import ch.ethz.seb.sebserver.gbl.api.POSTMapper;
import ch.ethz.seb.sebserver.gbl.model.Domain;
import ch.ethz.seb.sebserver.gbl.model.EntityKey;
@ -29,15 +26,12 @@ import ch.ethz.seb.sebserver.gbl.model.exam.ExamTemplate;
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.sebconfig.SEBClientConfig;
import ch.ethz.seb.sebserver.gbl.model.user.UserInfo;
import ch.ethz.seb.sebserver.gbl.model.user.UserMod;
import ch.ethz.seb.sebserver.gbl.model.user.UserRole;
import ch.ethz.seb.sebserver.gbl.profile.WebServiceProfile;
import ch.ethz.seb.sebserver.gbl.util.Result;
import ch.ethz.seb.sebserver.gbl.util.Utils;
import ch.ethz.seb.sebserver.webservice.WebserviceInfo;
import ch.ethz.seb.sebserver.webservice.servicelayer.authorization.UserService;
import ch.ethz.seb.sebserver.webservice.servicelayer.authorization.impl.SEBServerUser;
import ch.ethz.seb.sebserver.webservice.servicelayer.authorization.impl.TeacherAccountServiceImpl;
import ch.ethz.seb.sebserver.webservice.servicelayer.bulkaction.impl.DeleteExamAction;
import ch.ethz.seb.sebserver.webservice.servicelayer.dao.*;
import ch.ethz.seb.sebserver.webservice.servicelayer.dao.impl.ExamDeletionEvent;
@ -49,9 +43,9 @@ import ch.ethz.seb.sebserver.webservice.servicelayer.lms.LmsAPITemplate;
import ch.ethz.seb.sebserver.webservice.servicelayer.lms.impl.moodle.MoodleUtils;
import ch.ethz.seb.sebserver.webservice.servicelayer.sebconfig.ClientConfigService;
import ch.ethz.seb.sebserver.webservice.servicelayer.session.ExamSessionService;
import ch.ethz.seb.sebserver.webservice.servicelayer.session.ScreenProctoringService;
import org.apache.commons.lang3.StringUtils;
import org.joda.time.DateTime;
import org.joda.time.DateTimeZone;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
@ -70,7 +64,7 @@ public class FullLmsIntegrationServiceImpl implements FullLmsIntegrationService
private final LmsSetupDAO lmsSetupDAO;
private final UserActivityLogDAO userActivityLogDAO;
private final UserDAO userDAO;
private final TeacherAccountServiceImpl teacherAccountServiceImpl;
private final SEBClientConfigDAO sebClientConfigDAO;
private final ClientConfigService clientConfigService;
private final DeleteExamAction deleteExamAction;
@ -90,6 +84,7 @@ public class FullLmsIntegrationServiceImpl implements FullLmsIntegrationService
final UserActivityLogDAO userActivityLogDAO,
final UserDAO userDAO,
final SEBClientConfigDAO sebClientConfigDAO,
final ScreenProctoringService screenProctoringService,
final ClientConfigService clientConfigService,
final DeleteExamAction deleteExamAction,
final LmsAPIService lmsAPIService,
@ -100,13 +95,14 @@ public class FullLmsIntegrationServiceImpl implements FullLmsIntegrationService
final WebserviceInfo webserviceInfo,
final ClientHttpRequestFactoryService clientHttpRequestFactoryService,
final UserService userService,
final TeacherAccountServiceImpl teacherAccountServiceImpl,
@Value("${sebserver.webservice.lms.api.endpoint}") final String lmsAPIEndpoint,
@Value("${sebserver.webservice.lms.api.clientId}") final String clientId,
@Value("${sebserver.webservice.api.admin.clientSecret}") final String clientSecret) {
this.lmsSetupDAO = lmsSetupDAO;
this.userActivityLogDAO = userActivityLogDAO;
this.userDAO = userDAO;
this.teacherAccountServiceImpl = teacherAccountServiceImpl;
this.sebClientConfigDAO = sebClientConfigDAO;
this.clientConfigService = clientConfigService;
this.deleteExamAction = deleteExamAction;
@ -139,7 +135,11 @@ public class FullLmsIntegrationServiceImpl implements FullLmsIntegrationService
@Override
public void notifyExamDeletion(final ExamDeletionEvent event) {
event.ids.forEach(this::deleteAdHocAccount);
event.ids.forEach( examId -> {
this.examDAO.byPK(examId)
.map(this.teacherAccountServiceImpl::deleteTeacherAccountsForExam)
.onError(error -> log.warn("Failed delete teacher accounts for exam: {}", examId));
});
}
@Override
@ -255,14 +255,13 @@ public class FullLmsIntegrationServiceImpl implements FullLmsIntegrationService
final String quizId,
final String examTemplateId,
final String quitPassword,
final String quitLink,
final String timezone) {
final String quitLink) {
return lmsSetupDAO
.getLmsSetupIdByConnectionId(lmsUUID)
.flatMap(lmsAPIService::getLmsAPITemplate)
.map(findQuizData(courseId, quizId))
.map(createAccountAndExam(examTemplateId, quitPassword, timezone));
.map(createExam(examTemplateId, quitPassword));
}
@Override
@ -278,7 +277,7 @@ public class FullLmsIntegrationServiceImpl implements FullLmsIntegrationService
.flatMap(this::findExam)
.map(this::checkDeletion)
.map(this::logExamDeleted)
.map(this::deleteAdHocAccount)
.flatMap(teacherAccountServiceImpl::deleteTeacherAccountsForExam)
.flatMap(deleteExamAction::deleteExamFromLMSIntegration);
}
@ -341,6 +340,25 @@ public class FullLmsIntegrationServiceImpl implements FullLmsIntegrationService
}
}
@Override
public Result<String> getOneTimeLoginToken(
final String lmsUUID,
final String courseId,
final String quizId,
final String userId,
final String username,
final String timezone) {
return lmsSetupDAO
.getLmsSetupIdByConnectionId(lmsUUID)
.flatMap(lmsAPIService::getLmsAPITemplate)
.map(findQuizData(courseId, quizId))
.flatMap(this::findExam)
.flatMap(exam -> this.teacherAccountServiceImpl
.getOneTimeTokenForTeacherAccount(exam, userId, username, timezone, true));
}
private Function<LmsAPITemplate, QuizData> findQuizData(
final String courseId,
final String quizId) {
@ -375,10 +393,9 @@ public class FullLmsIntegrationServiceImpl implements FullLmsIntegrationService
return examDAO.byExternalIdLike(quizData.id);
}
private Function<QuizData, Exam> createAccountAndExam(
private Function<QuizData, Exam> createExam(
final String examTemplateId,
final String quitPassword,
final String timezone) {
final String quitPassword) {
return quizData -> {
@ -398,14 +415,6 @@ public class FullLmsIntegrationServiceImpl implements FullLmsIntegrationService
post.putIfAbsent(Domain.EXAM.ATTR_QUIT_PASSWORD, quitPassword);
}
final String accountUUID = createAdHocSupporterAccount(quizData, timezone);
if (accountUUID != null) {
post.putIfAbsent(Domain.EXAM.ATTR_OWNER, accountUUID);
// TODO do we need to apply the ad-hoc teacher account also as supporter?
} else {
post.putIfAbsent(Domain.EXAM.ATTR_OWNER, userService.getCurrentUser().uuid());
}
final Exam exam = new Exam(null, quizData, post);
return examDAO
.createNew(exam)
@ -415,83 +424,82 @@ public class FullLmsIntegrationServiceImpl implements FullLmsIntegrationService
};
}
private String createAdHocSupporterAccount(final QuizData data, final String timezone) {
try {
// private UserInfo createNewAdHocAccount(
// final Exam exam,
// final String userId,
// final String username,
// final String timezone) {
// try {
//
// final String uuid = UUID.randomUUID().toString();
// DateTimeZone dtz = DateTimeZone.UTC;
// if (StringUtils.isNotBlank(timezone)) {
// try {
// dtz = DateTimeZone.forID(timezone);
// } catch (final Exception e) {
// log.warn("Failed to set requested time zone for ad-hoc teacher account: {}", timezone);
// }
// }
//
// final UserMod adHocTeacherUser = new UserMod(
// uuid,
// exam.institutionId,
// userId,
// getTeacherAccountIdentifier(exam),
// username,
// uuid,
// uuid,
// null,
// Locale.ENGLISH,
// dtz,
// true,
// false,
// Utils.immutableSetOf(UserRole.TEACHER.name()));
//
// return userDAO.createNew(adHocTeacherUser)
// .flatMap(account -> userDAO.setActive(account, true))
// .getOrThrow();
//
// } catch (final Exception e) {
// log.error("Failed to create ad-hoc teacher account for importing exam: {}", exam, e);
// return null;
// }
// }
final String uuid = UUID.randomUUID().toString();
final String name = "teacher-" + uuid;
DateTimeZone dtz = DateTimeZone.UTC;
if (StringUtils.isNotBlank(timezone)) {
try {
dtz = DateTimeZone.forID(timezone);
} catch (final Exception e) {
log.warn("Failed to set requested time zone for ad-hoc teacher account: {}", timezone);
}
}
final UserMod adHocTeacherUser = new UserMod(
uuid,
data.institutionId,
name,
data.id,
name,
uuid,
uuid,
null,
Locale.ENGLISH,
dtz,
true,
false,
Utils.immutableSetOf(UserRole.TEACHER.name()));
userDAO.createNew(adHocTeacherUser)
.flatMap(account -> userDAO.setActive(account, true))
.getOrThrow();
// private Exam deleteAdHocAccounts(final Exam exam) {
// try {
//
// final String externalId = exam.externalId;
// final FilterMap filter = new FilterMap();
// filter.putIfAbsent(Domain.USER.ATTR_SURNAME, getTeacherAccountIdentifier(exam));
// final Collection<UserInfo> accounts = userDAO.allMatching(filter).getOrThrow();
//
// if (accounts.isEmpty()) {
// return exam;
// }
//
// if (accounts.size() > 1) {
// log.error("Too many accounts found!?... ad-hoc teacher account mapping: {}", externalId);
// return exam;
// }
//
// userDAO.delete(Utils.immutableSetOf(new EntityKey(
// accounts.iterator().next().uuid,
// EntityType.USER)))
// .getOrThrow();
//
// } catch (final Exception e) {
// log.error("Failed to delete ad-hoc account for exam: {}", exam, e);
// }
//
// return exam;
// }
return uuid;
} catch (final Exception e) {
log.error("Failed to create ad-hoc teacher account for importing exam: {}", data, e);
return null;
}
}
private Exam deleteAdHocAccount(final Exam exam) {
deleteAdHocAccount(exam.id);
return exam;
}
private void deleteAdHocAccount(final Long examId) {
try {
final Result<Exam> examResult = examDAO.byPK(examId);
if (examResult.hasError()) {
log.warn("Failed to get exam for id: {}", examId);
return;
}
final String externalId = examResult.get().externalId;
final FilterMap filter = new FilterMap();
filter.putIfAbsent(Domain.USER.ATTR_SURNAME, externalId);
final Collection<UserInfo> accounts = userDAO.allMatching(filter).getOrThrow();
if (accounts.isEmpty()) {
return;
}
if (accounts.size() > 1) {
log.error("Too many accounts found!?... ad-hoc teacher account mapping: {}", externalId);
return;
}
userDAO.delete(Utils.immutableSetOf(new EntityKey(
accounts.iterator().next().uuid,
EntityType.USER)))
.getOrThrow();
} catch (final Exception e) {
log.error("Failed to delete ad-hoc account for exam: {}", examId, e);
}
}
private Collection<ExamTemplateSelection> getIntegrationTemplates(final Long institutionId) {
return examTemplateDAO

View file

@ -91,6 +91,9 @@ public interface ScreenProctoringService extends SessionUpdateTask {
@Async(AsyncServiceSpringConfig.EXECUTOR_BEAN_NAME)
void synchronizeSPSUser(final String userUUID);
@Async(AsyncServiceSpringConfig.EXECUTOR_BEAN_NAME)
void synchronizeSPSUserForExam(final Long examId);
@Async(AsyncServiceSpringConfig.EXECUTOR_BEAN_NAME)
void deleteSPSUser(String userUUID);

View file

@ -1004,7 +1004,7 @@ class ScreenProctoringAPIBinding {
10 * Constants.SECOND_IN_MILLIS,
10 * Constants.SECOND_IN_MILLIS);
ClientCredentials clientCredentials = new ClientCredentials(
final ClientCredentials clientCredentials = new ClientCredentials(
spsAPIAccessData.getSpsAPIKey(),
spsAPIAccessData.getSpsAPISecret());

View file

@ -268,6 +268,13 @@ public class ScreenProctoringServiceImpl implements ScreenProctoringService {
this.screenProctoringAPIBinding.synchronizeUserAccount(userUUID);
}
@Override
public void synchronizeSPSUserForExam(final Long examId) {
this.examDAO.byPK(examId)
.onSuccess(this.screenProctoringAPIBinding::synchronizeUserAccounts)
.onError(error -> log.error("Failed to synchronize SPS user accounts for exam: {}", examId, error));
}
@Override
@Async(AsyncServiceSpringConfig.EXECUTOR_BEAN_NAME)
public void deleteSPSUser(final String userUUID) {

View file

@ -9,7 +9,6 @@
package ch.ethz.seb.sebserver.webservice.weblayer.api;
import javax.servlet.ServletOutputStream;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
@ -18,11 +17,9 @@ import java.io.PipedOutputStream;
import ch.ethz.seb.sebserver.gbl.api.API;
import ch.ethz.seb.sebserver.gbl.api.APIMessage;
import ch.ethz.seb.sebserver.gbl.model.Entity;
import ch.ethz.seb.sebserver.gbl.model.EntityKey;
import ch.ethz.seb.sebserver.gbl.model.exam.Exam;
import ch.ethz.seb.sebserver.gbl.profile.WebServiceProfile;
import ch.ethz.seb.sebserver.gbl.util.Result;
import ch.ethz.seb.sebserver.webservice.servicelayer.lms.FullLmsIntegrationService;
import org.apache.commons.io.IOUtils;
import org.slf4j.Logger;
@ -58,7 +55,6 @@ public class LmsIntegrationController {
@RequestParam(name = API.LMS_FULL_INTEGRATION_EXAM_TEMPLATE_ID, required = true) final String templateId,
@RequestParam(name = API.LMS_FULL_INTEGRATION_QUIT_PASSWORD, required = false) final String quitPassword,
@RequestParam(name = API.LMS_FULL_INTEGRATION_QUIT_LINK, required = false) final String quitLink,
@RequestParam(name = API.LMS_FULL_INTEGRATION_TIME_ZONE, required = false) final String timezone,
final HttpServletResponse response) {
final Exam exam = fullLmsIntegrationService.importExam(
@ -67,8 +63,7 @@ public class LmsIntegrationController {
quizId,
templateId,
quitPassword,
quitLink,
timezone)
quitLink)
.onError(e -> log.error(
"Failed to create/import exam: lmsId:{}, courseId: {}, quizId: {}, templateId: {}",
lmsUUId, courseId, quizId, templateId, e))
@ -107,33 +102,52 @@ public class LmsIntegrationController {
@RequestParam(name = API.LMS_FULL_INTEGRATION_QUIZ_ID, required = true) final String quizId,
final HttpServletResponse response) throws IOException {
final ServletOutputStream outputStream = response.getOutputStream();
final PipedOutputStream pout;
final PipedInputStream pin;
try {
pout = new PipedOutputStream();
pin = new PipedInputStream(pout);
final ServletOutputStream outputStream = response.getOutputStream();
final PipedOutputStream pout;
final PipedInputStream pin;
try {
pout = new PipedOutputStream();
pin = new PipedInputStream(pout);
fullLmsIntegrationService
.streamConnectionConfiguration(lmsUUId, courseId, quizId, pout)
.getOrThrow();
fullLmsIntegrationService
.streamConnectionConfiguration(lmsUUId, courseId, quizId, pout)
.getOrThrow();
IOUtils.copyLarge(pin, outputStream);
IOUtils.copyLarge(pin, outputStream);
response.setStatus(HttpStatus.OK.value());
outputStream.flush();
response.setStatus(HttpStatus.OK.value());
outputStream.flush();
} catch (final APIMessage.APIMessageException me) {
response.setStatus(HttpStatus.BAD_REQUEST.value());
throw me;
} catch (final Exception e) {
log.error(
"Failed to stream connection configuration for exam: lmsId:{}, courseId: {}, quizId: {}",
lmsUUId, courseId, quizId, e);
response.setStatus(HttpStatus.INTERNAL_SERVER_ERROR.value());
} finally {
outputStream.flush();
outputStream.close();
}
} catch (final APIMessage.APIMessageException me) {
response.setStatus(HttpStatus.BAD_REQUEST.value());
throw me;
} catch (final Exception e) {
log.error(
"Failed to stream connection configuration for exam: lmsId:{}, courseId: {}, quizId: {}",
lmsUUId, courseId, quizId, e);
response.setStatus(HttpStatus.INTERNAL_SERVER_ERROR.value());
} finally {
outputStream.flush();
outputStream.close();
}
}
@RequestMapping(
path = API.LMS_FULL_INTEGRATION_LOGIN_TOKEN_ENDPOINT,
method = RequestMethod.POST,
consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE,
produces = MediaType.APPLICATION_OCTET_STREAM_VALUE)
public String getOneTimeLoginToken(
@RequestParam(name = API.LMS_FULL_INTEGRATION_LMS_UUID, required = true) final String lmsUUId,
@RequestParam(name = API.LMS_FULL_INTEGRATION_COURSE_ID, required = true) final String courseId,
@RequestParam(name = API.LMS_FULL_INTEGRATION_QUIZ_ID, required = true) final String quizId,
@RequestParam(name = API.LMS_FULL_INTEGRATION_USER_ID, required = true) final String userId,
@RequestParam(name = API.LMS_FULL_INTEGRATION_USER_NAME, required = false) final String username,
@RequestParam(name = API.LMS_FULL_INTEGRATION_TIME_ZONE, required = false) final String timezone,
final HttpServletResponse response) throws IOException {
return this.fullLmsIntegrationService
.getOneTimeLoginToken(lmsUUId, courseId, quizId, userId, username, timezone)
.getOrThrow();
}
}