From 9618b942fb817d71e9137c77e870df1b429fc3f8 Mon Sep 17 00:00:00 2001 From: anhefti Date: Wed, 22 May 2024 13:32:30 +0200 Subject: [PATCH] SEBSERV-418 --- pom.xml | 7 +- .../ch/ethz/seb/sebserver/gbl/api/API.java | 7 +- .../gbl/model/user/TokenLoginInfo.java | 37 ++ .../ethz/seb/sebserver/gbl/util/Cryptor.java | 4 + .../MonitoringProctoringService.java | 2 +- .../authorization/TeacherAccountService.java | 36 ++ .../impl/TeacherAccountServiceImpl.java | 323 ++++++++++++++++++ .../webservice/servicelayer/dao/ExamDAO.java | 6 + .../servicelayer/dao/impl/ExamDAOImpl.java | 28 +- .../lms/FullLmsIntegrationService.java | 12 +- .../impl/FullLmsIntegrationServiceImpl.java | 200 +++++------ .../session/ScreenProctoringService.java | 3 + .../ScreenProctoringAPIBinding.java | 2 +- .../ScreenProctoringServiceImpl.java | 7 + .../api/LmsIntegrationController.java | 74 ++-- 15 files changed, 613 insertions(+), 135 deletions(-) create mode 100644 src/main/java/ch/ethz/seb/sebserver/gbl/model/user/TokenLoginInfo.java create mode 100644 src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/authorization/TeacherAccountService.java create mode 100644 src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/authorization/impl/TeacherAccountServiceImpl.java diff --git a/pom.xml b/pom.xml index 989e92f5..98546b84 100644 --- a/pom.xml +++ b/pom.xml @@ -348,13 +348,18 @@ org.apache.commons commons-text - 1.8 + 1.10.0 com.github.vladimir-bukhtoyarov bucket4j-core 4.10.0 + + io.jsonwebtoken + jjwt + 0.9.1 + diff --git a/src/main/java/ch/ethz/seb/sebserver/gbl/api/API.java b/src/main/java/ch/ethz/seb/sebserver/gbl/api/API.java index 4016de04..8841ff5f 100644 --- a/src/main/java/ch/ethz/seb/sebserver/gbl/api/API.java +++ b/src/main/java/ch/ethz/seb/sebserver/gbl/api/API.java @@ -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"; diff --git a/src/main/java/ch/ethz/seb/sebserver/gbl/model/user/TokenLoginInfo.java b/src/main/java/ch/ethz/seb/sebserver/gbl/model/user/TokenLoginInfo.java new file mode 100644 index 00000000..1be0526f --- /dev/null +++ b/src/main/java/ch/ethz/seb/sebserver/gbl/model/user/TokenLoginInfo.java @@ -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; + } +} diff --git a/src/main/java/ch/ethz/seb/sebserver/gbl/util/Cryptor.java b/src/main/java/ch/ethz/seb/sebserver/gbl/util/Cryptor.java index 38eb2aa5..7627f327 100644 --- a/src/main/java/ch/ethz/seb/sebserver/gbl/util/Cryptor.java +++ b/src/main/java/ch/ethz/seb/sebserver/gbl/util/Cryptor.java @@ -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 diff --git a/src/main/java/ch/ethz/seb/sebserver/gui/service/session/proctoring/MonitoringProctoringService.java b/src/main/java/ch/ethz/seb/sebserver/gui/service/session/proctoring/MonitoringProctoringService.java index 3b2587d6..ce123d1c 100644 --- a/src/main/java/ch/ethz/seb/sebserver/gui/service/session/proctoring/MonitoringProctoringService.java +++ b/src/main/java/ch/ethz/seb/sebserver/gui/service/session/proctoring/MonitoringProctoringService.java @@ -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(); diff --git a/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/authorization/TeacherAccountService.java b/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/authorization/TeacherAccountService.java new file mode 100644 index 00000000..b1ff53c1 --- /dev/null +++ b/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/authorization/TeacherAccountService.java @@ -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 createNewTeacherAccountForExam( + Exam exam, + String userId, + String username, + String timezone); + + Result deleteTeacherAccountsForExam(final Exam exam); + + Result getOneTimeTokenForTeacherAccount( + Exam exam, + String userId, + String username, + String timezone, + final boolean createIfNotExists); + + Result verifyOneTimeTokenForTeacherAccount(String token); + + +} diff --git a/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/authorization/impl/TeacherAccountServiceImpl.java b/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/authorization/impl/TeacherAccountServiceImpl.java new file mode 100644 index 00000000..5f3e680e --- /dev/null +++ b/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/authorization/impl/TeacherAccountServiceImpl.java @@ -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 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 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 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 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 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 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 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 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 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; + } +} diff --git a/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/dao/ExamDAO.java b/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/dao/ExamDAO.java index 72600ef0..1877150b 100644 --- a/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/dao/ExamDAO.java +++ b/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/dao/ExamDAO.java @@ -228,6 +228,11 @@ public interface ExamDAO extends ActivatableEntityDAO, BulkActionSup key = "#examId") void markUpdate(Long examId); + @CacheEvict( + cacheNames = ExamSessionCacheService.CACHE_NAME_RUNNING_EXAM, + key = "#examId") + Result 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, BulkActionSup void updateQuitPassword(Exam exam, String quitPassword); + } diff --git a/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/dao/impl/ExamDAOImpl.java b/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/dao/impl/ExamDAOImpl.java index 9433d7d8..e94391d9 100644 --- a/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/dao/impl/ExamDAOImpl.java +++ b/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/dao/impl/ExamDAOImpl.java @@ -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 applySupporter(final Exam exam, final String userUUID) { + return Result.tryCatch(() -> { + + final long millisecondsNow = Utils.getMillisecondsNow(); + final List 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) { diff --git a/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/lms/FullLmsIntegrationService.java b/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/lms/FullLmsIntegrationService.java index af9e36ac..e2d1592a 100644 --- a/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/lms/FullLmsIntegrationService.java +++ b/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/lms/FullLmsIntegrationService.java @@ -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 deleteExam( String lmsUUID, @@ -59,6 +57,14 @@ public interface FullLmsIntegrationService { String quizId, OutputStream out); + Result getOneTimeLoginToken( + String lmsUUId, + String courseId, + String quizId, + String userId, + String username, + String timezone); + @JsonIgnoreProperties(ignoreUnknown = true) final class IntegrationData { @JsonProperty("id") diff --git a/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/lms/impl/FullLmsIntegrationServiceImpl.java b/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/lms/impl/FullLmsIntegrationServiceImpl.java index 68754ed1..0e687436 100644 --- a/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/lms/impl/FullLmsIntegrationServiceImpl.java +++ b/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/lms/impl/FullLmsIntegrationServiceImpl.java @@ -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 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 findQuizData( final String courseId, final String quizId) { @@ -375,10 +393,9 @@ public class FullLmsIntegrationServiceImpl implements FullLmsIntegrationService return examDAO.byExternalIdLike(quizData.id); } - private Function createAccountAndExam( + private Function 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 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 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 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 getIntegrationTemplates(final Long institutionId) { return examTemplateDAO diff --git a/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/session/ScreenProctoringService.java b/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/session/ScreenProctoringService.java index 68021ea3..dfde2a8a 100644 --- a/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/session/ScreenProctoringService.java +++ b/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/session/ScreenProctoringService.java @@ -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); diff --git a/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/session/impl/proctoring/ScreenProctoringAPIBinding.java b/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/session/impl/proctoring/ScreenProctoringAPIBinding.java index e1efc298..714b8bdb 100644 --- a/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/session/impl/proctoring/ScreenProctoringAPIBinding.java +++ b/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/session/impl/proctoring/ScreenProctoringAPIBinding.java @@ -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()); diff --git a/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/session/impl/proctoring/ScreenProctoringServiceImpl.java b/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/session/impl/proctoring/ScreenProctoringServiceImpl.java index 408e251f..6163b5e6 100644 --- a/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/session/impl/proctoring/ScreenProctoringServiceImpl.java +++ b/src/main/java/ch/ethz/seb/sebserver/webservice/servicelayer/session/impl/proctoring/ScreenProctoringServiceImpl.java @@ -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) { diff --git a/src/main/java/ch/ethz/seb/sebserver/webservice/weblayer/api/LmsIntegrationController.java b/src/main/java/ch/ethz/seb/sebserver/webservice/weblayer/api/LmsIntegrationController.java index c7155d6e..6a3950f6 100644 --- a/src/main/java/ch/ethz/seb/sebserver/webservice/weblayer/api/LmsIntegrationController.java +++ b/src/main/java/ch/ethz/seb/sebserver/webservice/weblayer/api/LmsIntegrationController.java @@ -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(); + } }