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