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> <dependency>
<groupId>org.apache.commons</groupId> <groupId>org.apache.commons</groupId>
<artifactId>commons-text</artifactId> <artifactId>commons-text</artifactId>
<version>1.8</version> <version>1.10.0</version>
</dependency> </dependency>
<dependency> <dependency>
<groupId>com.github.vladimir-bukhtoyarov</groupId> <groupId>com.github.vladimir-bukhtoyarov</groupId>
<artifactId>bucket4j-core</artifactId> <artifactId>bucket4j-core</artifactId>
<version>4.10.0</version> <version>4.10.0</version>
</dependency> </dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>0.9.1</version>
</dependency>
<!-- Testing --> <!-- Testing -->

View file

@ -64,8 +64,11 @@ public final class API {
public static final String OAUTH_ENDPOINT = "/oauth"; public static final String OAUTH_ENDPOINT = "/oauth";
public static final String OAUTH_TOKEN_ENDPOINT = OAUTH_ENDPOINT + "/token"; 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 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_PASSWORD = "password";
public static final String GRANT_TYPE_CLIENT = "client_credentials"; 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_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_PASSWORD = "quit_password";
public static final String LMS_FULL_INTEGRATION_QUIT_LINK = "quit_link"; 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 LMS_FULL_INTEGRATION_TIME_ZONE = "account_time_zone";
public static final String USER_ACCOUNT_ENDPOINT = "/useraccount"; 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"); this.internalPWD = environment.getProperty("sebserver.webservice.internalSecret");
} }
public CharSequence getInternalPWD() {
return this.internalPWD;
}
/** Use this to encrypt a text with the internal password /** Use this to encrypt a text with the internal password
* *
* @param text The text to encrypt 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); String.class);
// JWT token request URL // 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 // Basic Auth header and content type header
final HttpHeaders httpHeaders = new HttpHeaders(); 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") key = "#examId")
void markUpdate(Long 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 /** 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 * @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); 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 ch.ethz.seb.sebserver.webservice.datalayer.batis.mapper.ExamRecordDynamicSqlSupport.examRecord;
import static org.mybatis.dynamic.sql.SqlBuilder.*; import static org.mybatis.dynamic.sql.SqlBuilder.*;
import java.sql.Array;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Arrays; import java.util.Arrays;
import java.util.Collection; import java.util.Collection;
@ -189,8 +190,7 @@ public class ExamDAOImpl implements ExamDAO {
UpdateDSL.updateWithMapper( UpdateDSL.updateWithMapper(
this.examRecordMapper::update, this.examRecordMapper::update,
ExamRecordDynamicSqlSupport.examRecord) ExamRecordDynamicSqlSupport.examRecord)
.set(ExamRecordDynamicSqlSupport.lastModified) .set(ExamRecordDynamicSqlSupport.lastModified).equalTo(millisecondsNow)
.equalTo(millisecondsNow)
.where(ExamRecordDynamicSqlSupport.id, isEqualTo(examId)) .where(ExamRecordDynamicSqlSupport.id, isEqualTo(examId))
.build() .build()
.execute(); .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 @Override
public void markLMSAvailability(final String externalQuizId, final boolean available, final String updateId) { 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.io.OutputStream;
import java.util.Collection; import java.util.Collection;
import java.util.Map;
import ch.ethz.seb.sebserver.gbl.model.EntityKey; import ch.ethz.seb.sebserver.gbl.model.EntityKey;
import ch.ethz.seb.sebserver.gbl.model.exam.Exam; import ch.ethz.seb.sebserver.gbl.model.exam.Exam;
@ -42,8 +41,7 @@ public interface FullLmsIntegrationService {
String quizId, String quizId,
String examTemplateId, String examTemplateId,
String quitPassword, String quitPassword,
String quitLink, String quitLink);
String timezone);
Result<EntityKey> deleteExam( Result<EntityKey> deleteExam(
String lmsUUID, String lmsUUID,
@ -59,6 +57,14 @@ public interface FullLmsIntegrationService {
String quizId, String quizId,
OutputStream out); OutputStream out);
Result<String> getOneTimeLoginToken(
String lmsUUId,
String courseId,
String quizId,
String userId,
String username,
String timezone);
@JsonIgnoreProperties(ignoreUnknown = true) @JsonIgnoreProperties(ignoreUnknown = true)
final class IntegrationData { final class IntegrationData {
@JsonProperty("id") @JsonProperty("id")

View file

@ -12,15 +12,12 @@ import java.io.OutputStream;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.util.Arrays; import java.util.Arrays;
import java.util.Collection; import java.util.Collection;
import java.util.Locale;
import java.util.UUID;
import java.util.function.Function; import java.util.function.Function;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import ch.ethz.seb.sebserver.ClientHttpRequestFactoryService; import ch.ethz.seb.sebserver.ClientHttpRequestFactoryService;
import ch.ethz.seb.sebserver.gbl.api.API; import ch.ethz.seb.sebserver.gbl.api.API;
import ch.ethz.seb.sebserver.gbl.api.APIMessage; 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.api.POSTMapper;
import ch.ethz.seb.sebserver.gbl.model.Domain; import ch.ethz.seb.sebserver.gbl.model.Domain;
import ch.ethz.seb.sebserver.gbl.model.EntityKey; 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.exam.QuizData;
import ch.ethz.seb.sebserver.gbl.model.institution.LmsSetup; 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.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.profile.WebServiceProfile;
import ch.ethz.seb.sebserver.gbl.util.Result; 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.WebserviceInfo;
import ch.ethz.seb.sebserver.webservice.servicelayer.authorization.UserService; 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.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.bulkaction.impl.DeleteExamAction;
import ch.ethz.seb.sebserver.webservice.servicelayer.dao.*; import ch.ethz.seb.sebserver.webservice.servicelayer.dao.*;
import ch.ethz.seb.sebserver.webservice.servicelayer.dao.impl.ExamDeletionEvent; 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.lms.impl.moodle.MoodleUtils;
import ch.ethz.seb.sebserver.webservice.servicelayer.sebconfig.ClientConfigService; 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.ExamSessionService;
import ch.ethz.seb.sebserver.webservice.servicelayer.session.ScreenProctoringService;
import org.apache.commons.lang3.StringUtils; import org.apache.commons.lang3.StringUtils;
import org.joda.time.DateTime; import org.joda.time.DateTime;
import org.joda.time.DateTimeZone;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
@ -70,7 +64,7 @@ public class FullLmsIntegrationServiceImpl implements FullLmsIntegrationService
private final LmsSetupDAO lmsSetupDAO; private final LmsSetupDAO lmsSetupDAO;
private final UserActivityLogDAO userActivityLogDAO; private final UserActivityLogDAO userActivityLogDAO;
private final UserDAO userDAO; private final TeacherAccountServiceImpl teacherAccountServiceImpl;
private final SEBClientConfigDAO sebClientConfigDAO; private final SEBClientConfigDAO sebClientConfigDAO;
private final ClientConfigService clientConfigService; private final ClientConfigService clientConfigService;
private final DeleteExamAction deleteExamAction; private final DeleteExamAction deleteExamAction;
@ -90,6 +84,7 @@ public class FullLmsIntegrationServiceImpl implements FullLmsIntegrationService
final UserActivityLogDAO userActivityLogDAO, final UserActivityLogDAO userActivityLogDAO,
final UserDAO userDAO, final UserDAO userDAO,
final SEBClientConfigDAO sebClientConfigDAO, final SEBClientConfigDAO sebClientConfigDAO,
final ScreenProctoringService screenProctoringService,
final ClientConfigService clientConfigService, final ClientConfigService clientConfigService,
final DeleteExamAction deleteExamAction, final DeleteExamAction deleteExamAction,
final LmsAPIService lmsAPIService, final LmsAPIService lmsAPIService,
@ -100,13 +95,14 @@ public class FullLmsIntegrationServiceImpl implements FullLmsIntegrationService
final WebserviceInfo webserviceInfo, final WebserviceInfo webserviceInfo,
final ClientHttpRequestFactoryService clientHttpRequestFactoryService, final ClientHttpRequestFactoryService clientHttpRequestFactoryService,
final UserService userService, final UserService userService,
final TeacherAccountServiceImpl teacherAccountServiceImpl,
@Value("${sebserver.webservice.lms.api.endpoint}") final String lmsAPIEndpoint, @Value("${sebserver.webservice.lms.api.endpoint}") final String lmsAPIEndpoint,
@Value("${sebserver.webservice.lms.api.clientId}") final String clientId, @Value("${sebserver.webservice.lms.api.clientId}") final String clientId,
@Value("${sebserver.webservice.api.admin.clientSecret}") final String clientSecret) { @Value("${sebserver.webservice.api.admin.clientSecret}") final String clientSecret) {
this.lmsSetupDAO = lmsSetupDAO; this.lmsSetupDAO = lmsSetupDAO;
this.userActivityLogDAO = userActivityLogDAO; this.userActivityLogDAO = userActivityLogDAO;
this.userDAO = userDAO; this.teacherAccountServiceImpl = teacherAccountServiceImpl;
this.sebClientConfigDAO = sebClientConfigDAO; this.sebClientConfigDAO = sebClientConfigDAO;
this.clientConfigService = clientConfigService; this.clientConfigService = clientConfigService;
this.deleteExamAction = deleteExamAction; this.deleteExamAction = deleteExamAction;
@ -139,7 +135,11 @@ public class FullLmsIntegrationServiceImpl implements FullLmsIntegrationService
@Override @Override
public void notifyExamDeletion(final ExamDeletionEvent event) { 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 @Override
@ -255,14 +255,13 @@ public class FullLmsIntegrationServiceImpl implements FullLmsIntegrationService
final String quizId, final String quizId,
final String examTemplateId, final String examTemplateId,
final String quitPassword, final String quitPassword,
final String quitLink, final String quitLink) {
final String timezone) {
return lmsSetupDAO return lmsSetupDAO
.getLmsSetupIdByConnectionId(lmsUUID) .getLmsSetupIdByConnectionId(lmsUUID)
.flatMap(lmsAPIService::getLmsAPITemplate) .flatMap(lmsAPIService::getLmsAPITemplate)
.map(findQuizData(courseId, quizId)) .map(findQuizData(courseId, quizId))
.map(createAccountAndExam(examTemplateId, quitPassword, timezone)); .map(createExam(examTemplateId, quitPassword));
} }
@Override @Override
@ -278,7 +277,7 @@ public class FullLmsIntegrationServiceImpl implements FullLmsIntegrationService
.flatMap(this::findExam) .flatMap(this::findExam)
.map(this::checkDeletion) .map(this::checkDeletion)
.map(this::logExamDeleted) .map(this::logExamDeleted)
.map(this::deleteAdHocAccount) .flatMap(teacherAccountServiceImpl::deleteTeacherAccountsForExam)
.flatMap(deleteExamAction::deleteExamFromLMSIntegration); .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( private Function<LmsAPITemplate, QuizData> findQuizData(
final String courseId, final String courseId,
final String quizId) { final String quizId) {
@ -375,10 +393,9 @@ public class FullLmsIntegrationServiceImpl implements FullLmsIntegrationService
return examDAO.byExternalIdLike(quizData.id); return examDAO.byExternalIdLike(quizData.id);
} }
private Function<QuizData, Exam> createAccountAndExam( private Function<QuizData, Exam> createExam(
final String examTemplateId, final String examTemplateId,
final String quitPassword, final String quitPassword) {
final String timezone) {
return quizData -> { return quizData -> {
@ -398,14 +415,6 @@ public class FullLmsIntegrationServiceImpl implements FullLmsIntegrationService
post.putIfAbsent(Domain.EXAM.ATTR_QUIT_PASSWORD, quitPassword); 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); final Exam exam = new Exam(null, quizData, post);
return examDAO return examDAO
.createNew(exam) .createNew(exam)
@ -415,83 +424,82 @@ public class FullLmsIntegrationServiceImpl implements FullLmsIntegrationService
}; };
} }
private String createAdHocSupporterAccount(final QuizData data, final String timezone) { // private UserInfo createNewAdHocAccount(
try { // 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) // private Exam deleteAdHocAccounts(final Exam exam) {
.flatMap(account -> userDAO.setActive(account, true)) // try {
.getOrThrow(); //
// 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) { private Collection<ExamTemplateSelection> getIntegrationTemplates(final Long institutionId) {
return examTemplateDAO return examTemplateDAO

View file

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

View file

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

View file

@ -268,6 +268,13 @@ public class ScreenProctoringServiceImpl implements ScreenProctoringService {
this.screenProctoringAPIBinding.synchronizeUserAccount(userUUID); 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 @Override
@Async(AsyncServiceSpringConfig.EXECUTOR_BEAN_NAME) @Async(AsyncServiceSpringConfig.EXECUTOR_BEAN_NAME)
public void deleteSPSUser(final String userUUID) { public void deleteSPSUser(final String userUUID) {

View file

@ -9,7 +9,6 @@
package ch.ethz.seb.sebserver.webservice.weblayer.api; package ch.ethz.seb.sebserver.webservice.weblayer.api;
import javax.servlet.ServletOutputStream; import javax.servlet.ServletOutputStream;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse; import javax.servlet.http.HttpServletResponse;
import java.io.IOException; 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.API;
import ch.ethz.seb.sebserver.gbl.api.APIMessage; 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.EntityKey;
import ch.ethz.seb.sebserver.gbl.model.exam.Exam; import ch.ethz.seb.sebserver.gbl.model.exam.Exam;
import ch.ethz.seb.sebserver.gbl.profile.WebServiceProfile; 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 ch.ethz.seb.sebserver.webservice.servicelayer.lms.FullLmsIntegrationService;
import org.apache.commons.io.IOUtils; import org.apache.commons.io.IOUtils;
import org.slf4j.Logger; 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_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_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_QUIT_LINK, required = false) final String quitLink,
@RequestParam(name = API.LMS_FULL_INTEGRATION_TIME_ZONE, required = false) final String timezone,
final HttpServletResponse response) { final HttpServletResponse response) {
final Exam exam = fullLmsIntegrationService.importExam( final Exam exam = fullLmsIntegrationService.importExam(
@ -67,8 +63,7 @@ public class LmsIntegrationController {
quizId, quizId,
templateId, templateId,
quitPassword, quitPassword,
quitLink, quitLink)
timezone)
.onError(e -> log.error( .onError(e -> log.error(
"Failed to create/import exam: lmsId:{}, courseId: {}, quizId: {}, templateId: {}", "Failed to create/import exam: lmsId:{}, courseId: {}, quizId: {}, templateId: {}",
lmsUUId, courseId, quizId, templateId, e)) 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, @RequestParam(name = API.LMS_FULL_INTEGRATION_QUIZ_ID, required = true) final String quizId,
final HttpServletResponse response) throws IOException { final HttpServletResponse response) throws IOException {
final ServletOutputStream outputStream = response.getOutputStream(); final ServletOutputStream outputStream = response.getOutputStream();
final PipedOutputStream pout; final PipedOutputStream pout;
final PipedInputStream pin; final PipedInputStream pin;
try { try {
pout = new PipedOutputStream(); pout = new PipedOutputStream();
pin = new PipedInputStream(pout); pin = new PipedInputStream(pout);
fullLmsIntegrationService fullLmsIntegrationService
.streamConnectionConfiguration(lmsUUId, courseId, quizId, pout) .streamConnectionConfiguration(lmsUUId, courseId, quizId, pout)
.getOrThrow(); .getOrThrow();
IOUtils.copyLarge(pin, outputStream); IOUtils.copyLarge(pin, outputStream);
response.setStatus(HttpStatus.OK.value()); response.setStatus(HttpStatus.OK.value());
outputStream.flush(); outputStream.flush();
} catch (final APIMessage.APIMessageException me) { } catch (final APIMessage.APIMessageException me) {
response.setStatus(HttpStatus.BAD_REQUEST.value()); response.setStatus(HttpStatus.BAD_REQUEST.value());
throw me; throw me;
} catch (final Exception e) { } catch (final Exception e) {
log.error( log.error(
"Failed to stream connection configuration for exam: lmsId:{}, courseId: {}, quizId: {}", "Failed to stream connection configuration for exam: lmsId:{}, courseId: {}, quizId: {}",
lmsUUId, courseId, quizId, e); lmsUUId, courseId, quizId, e);
response.setStatus(HttpStatus.INTERNAL_SERVER_ERROR.value()); response.setStatus(HttpStatus.INTERNAL_SERVER_ERROR.value());
} finally { } finally {
outputStream.flush(); outputStream.flush();
outputStream.close(); 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();
}
} }