diff --git a/src/main/java/de/tum/in/www1/artemis/aop/logging/LoggingAspect.java b/src/main/java/de/tum/in/www1/artemis/aop/logging/LoggingAspect.java index 9d6068b573cb..be14e97ea34e 100644 --- a/src/main/java/de/tum/in/www1/artemis/aop/logging/LoggingAspect.java +++ b/src/main/java/de/tum/in/www1/artemis/aop/logging/LoggingAspect.java @@ -1,6 +1,7 @@ package de.tum.in.www1.artemis.aop.logging; import java.util.Arrays; +import java.util.Objects; import org.aspectj.lang.JoinPoint; import org.aspectj.lang.ProceedingJoinPoint; @@ -13,6 +14,7 @@ import org.springframework.core.env.Environment; import org.springframework.core.env.Profiles; +import de.tum.in.www1.artemis.exception.localvc.LocalVCAuthException; import de.tum.in.www1.artemis.service.connectors.vcs.AbstractVersionControlService; import tech.jhipster.config.JHipsterConstants; @@ -62,6 +64,17 @@ public void logAfterThrowing(JoinPoint joinPoint, Throwable e) { return; } + if (e instanceof LocalVCAuthException) { + if (Objects.equals(e.getMessage(), "No authorization header provided")) { + // ignore, this is a common case and does not need to be logged + return; + } + else if (e.getMessage() != null && e.getMessage().startsWith("The username has to be")) { + // ignore, this is a common case and does not need to be logged + return; + } + } + if (env.acceptsProfiles(Profiles.of(JHipsterConstants.SPRING_PROFILE_DEVELOPMENT))) { log.error("Exception in {}.{}() with cause = '{}' and exception = '{}'", joinPoint.getSignature().getDeclaringTypeName(), joinPoint.getSignature().getName(), e.getCause() != null ? e.getCause() : "NULL", e.getMessage(), e); diff --git a/src/main/java/de/tum/in/www1/artemis/config/Constants.java b/src/main/java/de/tum/in/www1/artemis/config/Constants.java index 92cc3d3fd425..2f11ca5c6604 100644 --- a/src/main/java/de/tum/in/www1/artemis/config/Constants.java +++ b/src/main/java/de/tum/in/www1/artemis/config/Constants.java @@ -229,7 +229,9 @@ public final class Constants { public static final String INFO_SSH_KEYS_URL_DETAIL = "sshKeysURL"; - public static final String INFO_VERSION_CONTROL_ACCESS_TOKEN_DETAIL = "versionControlAccessToken"; + public static final String INFO_VERSION_CONTROL_ACCESS_TOKEN_DETAIL = "useVersionControlAccessToken"; + + public static final String INFO_SHOW_CLONE_URL_WITHOUT_TOKEN = "showCloneUrlWithoutToken"; public static final String REGISTRATION_ENABLED = "registrationEnabled"; diff --git a/src/main/java/de/tum/in/www1/artemis/domain/participation/ParticipationVCSAccessToken.java b/src/main/java/de/tum/in/www1/artemis/domain/participation/ParticipationVCSAccessToken.java new file mode 100644 index 000000000000..9e7da199ad4a --- /dev/null +++ b/src/main/java/de/tum/in/www1/artemis/domain/participation/ParticipationVCSAccessToken.java @@ -0,0 +1,56 @@ +package de.tum.in.www1.artemis.domain.participation; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; + +import org.hibernate.annotations.Cache; +import org.hibernate.annotations.CacheConcurrencyStrategy; + +import de.tum.in.www1.artemis.domain.DomainObject; +import de.tum.in.www1.artemis.domain.User; + +/** + * A ParticipationVcsAccessToken. + */ +@Entity +@Table(name = "participation_vcs_access_token", uniqueConstraints = { @UniqueConstraint(columnNames = { "user_id", "participation_id" }) }) +@Cache(usage = CacheConcurrencyStrategy.NONSTRICT_READ_WRITE) + +public class ParticipationVCSAccessToken extends DomainObject { + + @ManyToOne + private User user; + + @ManyToOne + private Participation participation; + + @Column(name = "vcs_access_token", length = 50) + private String vcsAccessToken; + + public void setUser(User user) { + this.user = user; + } + + public User getUser() { + return user; + } + + public void setParticipation(Participation participation) { + this.participation = participation; + } + + public Participation getParticipation() { + return participation; + } + + public String getVcsAccessToken() { + return vcsAccessToken; + } + + public void setVcsAccessToken(String vcsAccessToken) { + this.vcsAccessToken = vcsAccessToken; + } +} diff --git a/src/main/java/de/tum/in/www1/artemis/exception/localvc/LocalVCAuthException.java b/src/main/java/de/tum/in/www1/artemis/exception/localvc/LocalVCAuthException.java index 4ef109ef53cb..ce4a7a019b90 100644 --- a/src/main/java/de/tum/in/www1/artemis/exception/localvc/LocalVCAuthException.java +++ b/src/main/java/de/tum/in/www1/artemis/exception/localvc/LocalVCAuthException.java @@ -13,4 +13,9 @@ public LocalVCAuthException() { public LocalVCAuthException(Throwable cause) { super(cause); } + + public LocalVCAuthException(String message) { + super(message); + } + } diff --git a/src/main/java/de/tum/in/www1/artemis/exception/localvc/LocalVCOperationException.java b/src/main/java/de/tum/in/www1/artemis/exception/localvc/LocalVCOperationException.java index beb7ae5b3edc..cb0b8797fc76 100644 --- a/src/main/java/de/tum/in/www1/artemis/exception/localvc/LocalVCOperationException.java +++ b/src/main/java/de/tum/in/www1/artemis/exception/localvc/LocalVCOperationException.java @@ -12,4 +12,8 @@ public LocalVCOperationException() { public LocalVCOperationException(Throwable cause) { super(cause); } + + public LocalVCOperationException(String message) { + super(message); + } } diff --git a/src/main/java/de/tum/in/www1/artemis/repository/ParticipationVCSAccessTokenRepository.java b/src/main/java/de/tum/in/www1/artemis/repository/ParticipationVCSAccessTokenRepository.java new file mode 100644 index 000000000000..9f339eb72c79 --- /dev/null +++ b/src/main/java/de/tum/in/www1/artemis/repository/ParticipationVCSAccessTokenRepository.java @@ -0,0 +1,57 @@ +package de.tum.in.www1.artemis.repository; + +import static de.tum.in.www1.artemis.config.Constants.PROFILE_CORE; + +import java.util.Optional; + +import org.springframework.context.annotation.Profile; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; + +import de.tum.in.www1.artemis.domain.participation.ParticipationVCSAccessToken; +import de.tum.in.www1.artemis.repository.base.ArtemisJpaRepository; + +@Profile(PROFILE_CORE) +@Repository +public interface ParticipationVCSAccessTokenRepository extends ArtemisJpaRepository { + + /** + * Delete all participation vcs access token that belong to the given participation + * + * @param participationId the id of the participation where the tokens should be deleted + */ + @Transactional // ok because of delete + @Modifying + void deleteByParticipationId(long participationId); + + /** + * Delete all tokens of a user + * + * @param userId The id of the user + */ + @Transactional // ok because of delete + @Modifying + void deleteAllByUserId(long userId); + + @Query(""" + SELECT DISTINCT p + FROM ParticipationVCSAccessToken p + LEFT JOIN FETCH p.participation + LEFT JOIN FETCH p.user + WHERE p.user.id = :userId AND p.participation.id = :participationId + """) + Optional findByUserIdAndParticipationId(@Param("userId") long userId, @Param("participationId") long participationId); + + default ParticipationVCSAccessToken findByUserIdAndParticipationIdOrElseThrow(long userId, long participationId) { + return getValueElseThrow(findByUserIdAndParticipationId(userId, participationId)); + } + + default void findByUserIdAndParticipationIdAndThrowIfExists(long userId, long participationId) { + findByUserIdAndParticipationId(userId, participationId).ifPresent(token -> { + throw new IllegalStateException(); + }); + } +} diff --git a/src/main/java/de/tum/in/www1/artemis/repository/ProgrammingExerciseStudentParticipationRepository.java b/src/main/java/de/tum/in/www1/artemis/repository/ProgrammingExerciseStudentParticipationRepository.java index 8a8b99ec707d..2793a9c077a1 100644 --- a/src/main/java/de/tum/in/www1/artemis/repository/ProgrammingExerciseStudentParticipationRepository.java +++ b/src/main/java/de/tum/in/www1/artemis/repository/ProgrammingExerciseStudentParticipationRepository.java @@ -74,6 +74,8 @@ Optional findByIdWithAllResultsAndRelat Optional findByExerciseIdAndStudentLogin(long exerciseId, String username); + List findAllByExerciseIdAndStudentLogin(long exerciseId, String username); + default ProgrammingExerciseStudentParticipation findByExerciseIdAndStudentLoginOrThrow(long exerciseId, String username) { return getValueElseThrow(findByExerciseIdAndStudentLogin(exerciseId, username)); } @@ -113,6 +115,16 @@ Optional findWithEagerStudentsByExercis Optional findWithSubmissionsAndEagerStudentsByExerciseIdAndTeamShortName(@Param("exerciseId") long exerciseId, @Param("teamShortName") String teamShortName); + @Query(""" + SELECT DISTINCT participation + FROM ProgrammingExerciseStudentParticipation participation + LEFT JOIN FETCH participation.team team + LEFT JOIN FETCH team.students student + WHERE participation.exercise.id = :exerciseId + AND student.id = :studentId + """) + Optional findTeamParticipationByExerciseIdAndStudentId(@Param("exerciseId") long exerciseId, @Param("studentId") long studentId); + List findByExerciseId(long exerciseId); @EntityGraph(type = LOAD, attributePaths = { "submissions", "team.students" }) diff --git a/src/main/java/de/tum/in/www1/artemis/service/ParticipationService.java b/src/main/java/de/tum/in/www1/artemis/service/ParticipationService.java index f1cb7d607d93..d579f5765083 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/ParticipationService.java +++ b/src/main/java/de/tum/in/www1/artemis/service/ParticipationService.java @@ -101,6 +101,8 @@ public class ParticipationService { private final ProfileService profileService; + private final ParticipationVcsAccessTokenService participationVCSAccessTokenService; + private final CompetencyProgressService competencyProgressService; public ParticipationService(GitService gitService, Optional continuousIntegrationService, Optional versionControlService, @@ -109,7 +111,8 @@ public ParticipationService(GitService gitService, Optional localCISharedBuildJobQueueService, ProfileService profileService, CompetencyProgressService competencyProgressService) { + Optional localCISharedBuildJobQueueService, ProfileService profileService, + ParticipationVcsAccessTokenService participationVCSAccessTokenService, CompetencyProgressService competencyProgressService) { this.gitService = gitService; this.continuousIntegrationService = continuousIntegrationService; this.versionControlService = versionControlService; @@ -129,6 +132,7 @@ public ParticipationService(GitService gitService, Optional gitlabSshKeysUrlPath; - @Value("${artemis.version-control.version-control-access-token:#{false}}") - private Boolean versionControlAccessToken; + @Value("${artemis.version-control.use-version-control-access-token:#{false}}") + private Boolean useVersionControlAccessToken; + + @Value("${artemis.version-control.show-clone-url-without-token:true}") + private boolean showCloneUrlWithoutToken; @Override public void contribute(Info.Builder builder) { @@ -49,6 +52,7 @@ public void contribute(Info.Builder builder) { } } - builder.withDetail(Constants.INFO_VERSION_CONTROL_ACCESS_TOKEN_DETAIL, versionControlAccessToken); + builder.withDetail(Constants.INFO_VERSION_CONTROL_ACCESS_TOKEN_DETAIL, useVersionControlAccessToken); + builder.withDetail(Constants.INFO_SHOW_CLONE_URL_WITHOUT_TOKEN, showCloneUrlWithoutToken); } } diff --git a/src/main/java/de/tum/in/www1/artemis/service/connectors/localvc/LocalVCInfoContributor.java b/src/main/java/de/tum/in/www1/artemis/service/connectors/localvc/LocalVCInfoContributor.java index 358b0c9490fd..43b9bea1b531 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/connectors/localvc/LocalVCInfoContributor.java +++ b/src/main/java/de/tum/in/www1/artemis/service/connectors/localvc/LocalVCInfoContributor.java @@ -28,6 +28,12 @@ public class LocalVCInfoContributor implements InfoContributor { @Value("${server.url}") private String artemisServerUrl; + @Value("${artemis.version-control.use-version-control-access-token:false}") + private boolean useVcsAccessToken; + + @Value("${artemis.version-control.show-clone-url-without-token:true}") + private boolean showCloneUrlWithoutToken; + @Value("${artemis.version-control.ssh-port:7921}") private int sshPort; @@ -40,10 +46,10 @@ public void contribute(Info.Builder builder) { builder.withDetail(Constants.VERSION_CONTROL_NAME, "Local VC"); // Show the access token in case it is available in the clone URL - // TODO: only activate this when access tokens are available and make sure this does not lead to issues - // TODO: If activated, reflect this in LocalVCInfoContributorTest // with the account.service.ts and its check if the access token is required - builder.withDetail(Constants.INFO_VERSION_CONTROL_ACCESS_TOKEN_DETAIL, false); + // TODO: Find a better way to test this in LocalVCInfoContributorTest + builder.withDetail(Constants.INFO_VERSION_CONTROL_ACCESS_TOKEN_DETAIL, useVcsAccessToken); + builder.withDetail(Constants.INFO_SHOW_CLONE_URL_WITHOUT_TOKEN, showCloneUrlWithoutToken); // Store ssh url template try { diff --git a/src/main/java/de/tum/in/www1/artemis/service/connectors/localvc/LocalVCPersonalAccessTokenManagementService.java b/src/main/java/de/tum/in/www1/artemis/service/connectors/localvc/LocalVCPersonalAccessTokenManagementService.java new file mode 100644 index 000000000000..628046de3825 --- /dev/null +++ b/src/main/java/de/tum/in/www1/artemis/service/connectors/localvc/LocalVCPersonalAccessTokenManagementService.java @@ -0,0 +1,38 @@ +package de.tum.in.www1.artemis.service.connectors.localvc; + +import static de.tum.in.www1.artemis.config.Constants.PROFILE_LOCALVC; + +import java.security.SecureRandom; +import java.util.stream.Collectors; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Service; + +@Service +@Profile(PROFILE_LOCALVC) +public class LocalVCPersonalAccessTokenManagementService { + + private static final Logger log = LoggerFactory.getLogger(LocalVCPersonalAccessTokenManagementService.class); + + public static final String TOKEN_PREFIX = "vcpat-"; + + public static final int VCS_ACCESS_TOKEN_LENGTH = 50; // database stores token as varchar(50) + + private static final int RANDOM_STRING_LENGTH = VCS_ACCESS_TOKEN_LENGTH - TOKEN_PREFIX.length(); + + private static final String[] CHARACTERS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789".split(""); + + private static final SecureRandom RANDOM = new SecureRandom(); + + /** + * Generates a secure vcs access token + * + * @return the token + */ + public static String generateSecureVCSAccessToken() { + log.debug("Generate secure vcs access token"); + return TOKEN_PREFIX + RANDOM.ints(RANDOM_STRING_LENGTH, 0, CHARACTERS.length).mapToObj(it -> CHARACTERS[it]).collect(Collectors.joining("")); + } +} diff --git a/src/main/java/de/tum/in/www1/artemis/service/connectors/localvc/LocalVCServletService.java b/src/main/java/de/tum/in/www1/artemis/service/connectors/localvc/LocalVCServletService.java index 684149574974..fcb439a28f25 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/connectors/localvc/LocalVCServletService.java +++ b/src/main/java/de/tum/in/www1/artemis/service/connectors/localvc/LocalVCServletService.java @@ -1,16 +1,21 @@ package de.tum.in.www1.artemis.service.connectors.localvc; import static de.tum.in.www1.artemis.config.Constants.PROFILE_LOCALVC; +import static de.tum.in.www1.artemis.service.connectors.localvc.LocalVCPersonalAccessTokenManagementService.TOKEN_PREFIX; +import static de.tum.in.www1.artemis.service.connectors.localvc.LocalVCPersonalAccessTokenManagementService.VCS_ACCESS_TOKEN_LENGTH; import java.io.IOException; import java.net.URL; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; +import java.time.ZonedDateTime; import java.util.Base64; import java.util.HashMap; +import java.util.List; import java.util.Map; import java.util.Objects; +import java.util.Optional; import jakarta.servlet.http.HttpServletRequest; @@ -38,12 +43,14 @@ import de.tum.in.www1.artemis.domain.User; import de.tum.in.www1.artemis.domain.enumeration.RepositoryType; import de.tum.in.www1.artemis.domain.participation.ProgrammingExerciseParticipation; +import de.tum.in.www1.artemis.domain.participation.ProgrammingExerciseStudentParticipation; import de.tum.in.www1.artemis.domain.participation.SolutionProgrammingExerciseParticipation; import de.tum.in.www1.artemis.exception.ContinuousIntegrationException; import de.tum.in.www1.artemis.exception.VersionControlException; import de.tum.in.www1.artemis.exception.localvc.LocalVCAuthException; import de.tum.in.www1.artemis.exception.localvc.LocalVCForbiddenException; import de.tum.in.www1.artemis.exception.localvc.LocalVCInternalException; +import de.tum.in.www1.artemis.repository.ParticipationVCSAccessTokenRepository; import de.tum.in.www1.artemis.repository.ProgrammingExerciseRepository; import de.tum.in.www1.artemis.repository.UserRepository; import de.tum.in.www1.artemis.security.SecurityUtils; @@ -95,6 +102,8 @@ public class LocalVCServletService { private static URL localVCBaseUrl; + private final ParticipationVCSAccessTokenRepository participationVCSAccessTokenRepository; + @Value("${artemis.version-control.url}") public void setLocalVCBaseUrl(URL localVCBaseUrl) { LocalVCServletService.localVCBaseUrl = localVCBaseUrl; @@ -122,7 +131,8 @@ public LocalVCServletService(AuthenticationManager authenticationManager, UserRe RepositoryAccessService repositoryAccessService, AuthorizationCheckService authorizationCheckService, ProgrammingExerciseParticipationService programmingExerciseParticipationService, AuxiliaryRepositoryService auxiliaryRepositoryService, ContinuousIntegrationTriggerService ciTriggerService, ProgrammingSubmissionService programmingSubmissionService, - ProgrammingMessagingService programmingMessagingService, ProgrammingTriggerService programmingTriggerService) { + ProgrammingMessagingService programmingMessagingService, ProgrammingTriggerService programmingTriggerService, + ParticipationVCSAccessTokenRepository participationVCSAccessTokenRepository) { this.authenticationManager = authenticationManager; this.userRepository = userRepository; this.programmingExerciseRepository = programmingExerciseRepository; @@ -134,6 +144,7 @@ public LocalVCServletService(AuthenticationManager authenticationManager, UserRe this.programmingSubmissionService = programmingSubmissionService; this.programmingMessagingService = programmingMessagingService; this.programmingTriggerService = programmingTriggerService; + this.participationVCSAccessTokenRepository = participationVCSAccessTokenRepository; } /** @@ -205,14 +216,11 @@ public void authenticateAndAuthorizeGitRequest(HttpServletRequest request, Repos } } - User user = authenticateUser(authorizationHeader); - // Optimization. // For each git command (i.e. 'git fetch' or 'git push'), the git client sends three requests. // The URLs of the first two requests end on '[repository URI]/info/refs'. The third one ends on '[repository URI]/git-receive-pack' (for push) and '[repository // URL]/git-upload-pack' (for fetch). // The following checks will only be conducted for the second request, so we do not have to access the database too often. - // The first request does not contain credentials and will thus already be blocked by the 'authenticateUser' method above. if (!request.getRequestURI().endsWith("/info/refs")) { return; } @@ -223,6 +231,8 @@ public void authenticateAndAuthorizeGitRequest(HttpServletRequest request, Repos ProgrammingExercise exercise = getProgrammingExerciseOrThrow(projectKey); + User user = authenticateUser(authorizationHeader, exercise, localVCRepositoryUri); + // Check that offline IDE usage is allowed. if (Boolean.FALSE.equals(exercise.isAllowOfflineIde()) && authorizationCheckService.isOnlyStudentInCourse(exercise.getCourseViaExerciseGroupOrCourseMember(), user)) { throw new LocalVCForbiddenException(); @@ -235,37 +245,64 @@ public void authenticateAndAuthorizeGitRequest(HttpServletRequest request, Repos log.debug("Authorizing user {} for repository {} took {}", user.getLogin(), localVCRepositoryUri, TimeLogUtil.formatDurationFrom(timeNanoStart)); } - private User authenticateUser(String authorizationHeader) throws LocalVCAuthException { + private User authenticateUser(String authorizationHeader, ProgrammingExercise exercise, LocalVCRepositoryUri localVCRepositoryUri) throws LocalVCAuthException { UsernameAndPassword usernameAndPassword = extractUsernameAndPassword(authorizationHeader); String username = usernameAndPassword.username(); String password = usernameAndPassword.password(); - - var user = userRepository.findOneByLogin(username); + User user = userRepository.findOneByLogin(username).orElseThrow(LocalVCAuthException::new); try { SecurityUtils.checkUsernameAndPasswordValidity(username, password); + } + catch (AccessForbiddenException | AuthenticationException e) { + if (!StringUtils.isEmpty(password)) { + log.warn("Failed login attempt for user {} with password {} due to issue: {}", username, password, e.getMessage()); + } + throw new LocalVCAuthException(e.getMessage()); + } + + // check user VCS access token + if (Objects.equals(user.getVcsAccessToken(), password) && user.getVcsAccessTokenExpiryDate() != null && user.getVcsAccessTokenExpiryDate().isAfter(ZonedDateTime.now())) { + return user; + } - // Note: we first check if the user has used a vcs access token instead of a password + // Note: we first check if the user has used a vcs access token instead of a password + if (password.startsWith(TOKEN_PREFIX) && password.length() == VCS_ACCESS_TOKEN_LENGTH) { + try { - if (user.isPresent() && !StringUtils.isEmpty(user.get().getVcsAccessToken()) && Objects.equals(user.get().getVcsAccessToken(), password)) { - // user is authenticated by using the correct access token - return user.get(); + // check participation vcs access token + // var part = programmingExerciseParticipationService.findTeamParticipationByExerciseAndTeamShortNameOrThrow() + List participations; + Optional studentParticipation; + if (exercise.isTeamMode()) { + studentParticipation = programmingExerciseParticipationService.findTeamParticipationByExerciseAndUser(exercise, user); + } + else { + participations = programmingExerciseParticipationService.findStudentParticipationsByExerciseAndStudentId(exercise, user.getLogin()); + studentParticipation = participations.stream().filter(participation -> participation.getRepositoryUri().equals(localVCRepositoryUri.toString())).findAny(); + } + if (studentParticipation.isPresent()) { + var token = participationVCSAccessTokenRepository.findByUserIdAndParticipationId(user.getId(), studentParticipation.get().getId()); + if (token.isPresent() && Objects.equals(token.get().getVcsAccessToken(), password)) { + user.setVcsAccessToken(token.get().getVcsAccessToken()); + return user; + } + } } + catch (EntityNotFoundException e) { + throw new LocalVCAuthException(); + } + } - // if the user does not have an access token or has used a password, we try to authenticate the user with it + // if the user does not have an access token or has used a password, we try to authenticate the user with it - // Try to authenticate the user based on the configured options, this can include sending the data to an external system (e.g. LDAP) or using internal authentication. - UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(username, password); - authenticationManager.authenticate(authenticationToken); - } - catch (AccessForbiddenException | AuthenticationException e) { - throw new LocalVCAuthException(e); - } + // Try to authenticate the user based on the configured options, this can include sending the data to an external system (e.g. LDAP) or using internal authentication. + UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(username, password); + authenticationManager.authenticate(authenticationToken); - // Check that the user exists. - return user.orElseThrow(LocalVCAuthException::new); + return user; } /** @@ -307,7 +344,7 @@ private ProgrammingExercise getProgrammingExerciseOrThrow(String projectKey) { private String checkAuthorizationHeader(String authorizationHeader) throws LocalVCAuthException { if (authorizationHeader == null) { - throw new LocalVCAuthException(); + throw new LocalVCAuthException("No authorization header provided"); } String[] basicAuthCredentialsEncoded = authorizationHeader.split(" "); diff --git a/src/main/java/de/tum/in/www1/artemis/service/connectors/vcs/VcsTokenRenewalService.java b/src/main/java/de/tum/in/www1/artemis/service/connectors/vcs/VcsTokenRenewalService.java index 784a1a31bb77..53c115bf12de 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/connectors/vcs/VcsTokenRenewalService.java +++ b/src/main/java/de/tum/in/www1/artemis/service/connectors/vcs/VcsTokenRenewalService.java @@ -42,12 +42,12 @@ public class VcsTokenRenewalService { /** * The config parameter for enabling VCS access tokens. */ - private final boolean versionControlAccessToken; + private final boolean useVersionControlAccessToken; // note: we inject the configuration value here to easily test with different ones - public VcsTokenRenewalService(@Value("${artemis.version-control.version-control-access-token:#{false}}") boolean versionControlAccessToken, + public VcsTokenRenewalService(@Value("${artemis.version-control.use-version-control-access-token:#{false}}") boolean versionControlAccessToken, Optional vcsTokenManagementService, UserRepository userRepository) { - this.versionControlAccessToken = versionControlAccessToken; + this.useVersionControlAccessToken = versionControlAccessToken; this.vcsTokenManagementService = vcsTokenManagementService; this.userRepository = userRepository; } @@ -59,7 +59,7 @@ public VcsTokenRenewalService(@Value("${artemis.version-control.version-control- */ @Scheduled(cron = "0 0 4 * * SUN") // Every sunday at 4 am public void renewAllVcsAccessTokens() { - if (versionControlAccessToken && vcsTokenManagementService.isPresent()) { + if (useVersionControlAccessToken && vcsTokenManagementService.isPresent()) { log.info("Started scheduled access token renewal"); int renewedAccessTokenCount = renewExpiringAccessTokens(); int createdAccessTokenCount = createMissingAccessTokens(); diff --git a/src/main/java/de/tum/in/www1/artemis/service/programming/ProgrammingExerciseParticipationService.java b/src/main/java/de/tum/in/www1/artemis/service/programming/ProgrammingExerciseParticipationService.java index d40a546decc5..703f4c3d3799 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/programming/ProgrammingExerciseParticipationService.java +++ b/src/main/java/de/tum/in/www1/artemis/service/programming/ProgrammingExerciseParticipationService.java @@ -154,6 +154,18 @@ public ProgrammingExerciseStudentParticipation findTeamParticipationByExerciseAn return participationOptional.get(); } + /** + * Tries to retrieve a student participation for the given team exercise and user + * + * @param exercise the exercise for which to find a participation. + * @param user the user who is member of the team to which the participation belongs. + * @return the participation for the given exercise and user. + * @throws EntityNotFoundException if there is no participation for the given exercise and user. + */ + public Optional findTeamParticipationByExerciseAndUser(ProgrammingExercise exercise, User user) { + return studentParticipationRepository.findTeamParticipationByExerciseIdAndStudentId(exercise.getId(), user.getId()); + } + /** * Tries to retrieve a student participation for the given exercise and username and test run flag. * @@ -208,6 +220,19 @@ public ProgrammingExerciseStudentParticipation findStudentParticipationByExercis return participation.get(); } + /** + * Tries to retrieve all student participation for the given exercise id and username. + * + * @param exercise the exercise for which to find a participation + * @param username of the user to which the participation belongs. + * @return the participations for the given exercise and user. + * @throws EntityNotFoundException if there is no participation for the given exercise and user. + */ + @NotNull + public List findStudentParticipationsByExerciseAndStudentId(Exercise exercise, String username) throws EntityNotFoundException { + return studentParticipationRepository.findAllByExerciseIdAndStudentLogin(exercise.getId(), username); + } + /** * Try to find a programming exercise participation for the given id. * It contains the last submission which might be illegal! diff --git a/src/main/java/de/tum/in/www1/artemis/service/user/UserService.java b/src/main/java/de/tum/in/www1/artemis/service/user/UserService.java index 4e8bf2156c53..e1f1895553aa 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/user/UserService.java +++ b/src/main/java/de/tum/in/www1/artemis/service/user/UserService.java @@ -41,6 +41,7 @@ import de.tum.in.www1.artemis.domain.Authority; import de.tum.in.www1.artemis.domain.GuidedTourSetting; import de.tum.in.www1.artemis.domain.User; +import de.tum.in.www1.artemis.domain.participation.ParticipationVCSAccessToken; import de.tum.in.www1.artemis.exception.AccountRegistrationBlockedException; import de.tum.in.www1.artemis.exception.UsernameAlreadyUsedException; import de.tum.in.www1.artemis.exception.VersionControlException; @@ -51,6 +52,7 @@ import de.tum.in.www1.artemis.security.SecurityUtils; import de.tum.in.www1.artemis.service.FilePathService; import de.tum.in.www1.artemis.service.FileService; +import de.tum.in.www1.artemis.service.ParticipationVcsAccessTokenService; import de.tum.in.www1.artemis.service.connectors.ci.CIUserManagementService; import de.tum.in.www1.artemis.service.connectors.ldap.LdapAuthenticationProvider; import de.tum.in.www1.artemis.service.connectors.vcs.VcsUserManagementService; @@ -109,10 +111,13 @@ public class UserService { private final ScienceEventRepository scienceEventRepository; + private final ParticipationVcsAccessTokenService participationVCSAccessTokenService; + public UserService(UserCreationService userCreationService, UserRepository userRepository, AuthorityService authorityService, AuthorityRepository authorityRepository, CacheManager cacheManager, Optional ldapUserService, GuidedTourSettingsRepository guidedTourSettingsRepository, PasswordService passwordService, Optional optionalVcsUserManagementService, Optional optionalCIUserManagementService, - InstanceMessageSendService instanceMessageSendService, FileService fileService, ScienceEventRepository scienceEventRepository) { + InstanceMessageSendService instanceMessageSendService, FileService fileService, ScienceEventRepository scienceEventRepository, + ParticipationVcsAccessTokenService participationVCSAccessTokenService) { this.userCreationService = userCreationService; this.userRepository = userRepository; this.authorityService = authorityService; @@ -126,6 +131,7 @@ public UserService(UserCreationService userCreationService, UserRepository userR this.instanceMessageSendService = instanceMessageSendService; this.fileService = fileService; this.scienceEventRepository = scienceEventRepository; + this.participationVCSAccessTokenService = participationVCSAccessTokenService; } /** @@ -309,6 +315,7 @@ public User registerUser(UserDTO userDTO, String password) { } catch (VersionControlException e) { log.error("An error occurred while registering GitLab user {}:", savedNonActivatedUser.getLogin(), e); + participationVCSAccessTokenService.deleteAllByUserId(savedNonActivatedUser.getId()); userRepository.delete(savedNonActivatedUser); clearUserCaches(savedNonActivatedUser); userRepository.flush(); @@ -456,6 +463,7 @@ public void updateUserInConnectorsAndAuthProvider(User user, String oldUserLogin */ public void softDeleteUser(String login) { userRepository.findOneWithGroupsByLogin(login).ifPresent(user -> { + participationVCSAccessTokenService.deleteAllByUserId(user.getId()); user.setDeleted(true); anonymizeUser(user); log.warn("Soft Deleted User: {}", user); @@ -814,4 +822,28 @@ public List importUsers(List userDtos) { return notFoundUsers; } + + /** + * Get the vcs access token associated with a user and a participation + * + * @param user the user associated with the vcs access token + * @param participationId the participation's participationId associated with the vcs access token + * + * @return the users participation vcs access token, or throws an exception if it does not exist + */ + public ParticipationVCSAccessToken getParticipationVcsAccessTokenForUserAndParticipationIdOrElseThrow(User user, Long participationId) { + return participationVCSAccessTokenService.findByUserIdAndParticipationIdOrElseThrow(user.getId(), participationId); + } + + /** + * Create a vcs access token associated with a user and a participation, and return it + * + * @param user the user associated with the vcs access token + * @param participationId the participation's participationId associated with the vcs access token + * + * @return the users newly created participation vcs access token, or throws an exception if it already existed + */ + public ParticipationVCSAccessToken createParticipationVcsAccessTokenForUserAndParticipationIdOrElseThrow(User user, Long participationId) { + return participationVCSAccessTokenService.createVcsAccessTokenForUserAndParticipationIdOrElseThrow(user, participationId); + } } diff --git a/src/main/java/de/tum/in/www1/artemis/web/rest/UserResource.java b/src/main/java/de/tum/in/www1/artemis/web/rest/UserResource.java index 90985b9a6f96..747208042837 100644 --- a/src/main/java/de/tum/in/www1/artemis/web/rest/UserResource.java +++ b/src/main/java/de/tum/in/www1/artemis/web/rest/UserResource.java @@ -225,4 +225,36 @@ public ResponseEntity deleteSshPublicKey() { log.debug("Successfully deleted SSH key of user {}", user.getLogin()); return ResponseEntity.ok().build(); } + + /** + * GET users/vcsToken : get the vcsToken for of a user for a participation + * + * @param participationId the participation for which the access token should be fetched + * + * @return the versionControlAccessToken belonging to the provided participation and user + */ + @GetMapping("users/vcsToken") + @EnforceAtLeastStudent + public ResponseEntity getVcsAccessToken(@RequestParam("participationId") Long participationId) { + User user = userRepository.getUser(); + + log.debug("REST request to get VCS access token of user {} for participation {}", user.getLogin(), participationId); + return ResponseEntity.ok(userService.getParticipationVcsAccessTokenForUserAndParticipationIdOrElseThrow(user, participationId).getVcsAccessToken()); + } + + /** + * PUT users/vcsToken : get the vcsToken for of a user for a participation + * + * @param participationId the participation for which the access token should be fetched + * + * @return the versionControlAccessToken belonging to the provided participation and user + */ + @PutMapping("users/vcsToken") + @EnforceAtLeastStudent + public ResponseEntity createVcsAccessToken(@RequestParam("participationId") Long participationId) { + User user = userRepository.getUser(); + + log.debug("REST request to create a new VCS access token for user {} for participation {}", user.getLogin(), participationId); + return ResponseEntity.ok(userService.createParticipationVcsAccessTokenForUserAndParticipationIdOrElseThrow(user, participationId).getVcsAccessToken()); + } } diff --git a/src/main/resources/config/application-artemis.yml b/src/main/resources/config/application-artemis.yml index 4b095afb3bee..9b2c2e3f40f7 100644 --- a/src/main/resources/config/application-artemis.yml +++ b/src/main/resources/config/application-artemis.yml @@ -58,7 +58,7 @@ artemis: # ssh-private-key-folder-path: # the path to the folder in which the private ssh key file (e.g. id_rsa) is stored that can be used to clone git repos on the version control server # ssh-private-key-password: # the password for the private ssh key default-branch: main # The branch that should be used as default branch for all newly created repositories. This does NOT have to be equal to the default branch of the VCS - version-control-access-token: false # only for Gitlab setups: a Gitlab-API token can be generated for each user and used as part of the Git clone URL shown to students to allow for password-less Git operations via HTTP + use-version-control-access-token: true # for Gitlab and LocalVC setups. For gitlab: a Gitlab-API token can be generated for each user and used as part of the Git clone URL shown to students to allow for password-less Git operations via HTTP. For LocalVC: Artemis generates access tokens for users to use repositories similar to the gitlab setup continuous-integration: user: # e.g. ga12abc password: diff --git a/src/main/resources/config/application-localvc.yml b/src/main/resources/config/application-localvc.yml index 482df6390d9e..76489b18a61f 100644 --- a/src/main/resources/config/application-localvc.yml +++ b/src/main/resources/config/application-localvc.yml @@ -12,5 +12,6 @@ artemis: url: http://localhost:8000 build-agent-git-username: buildjob_user # Replace with more secure credentials for production. Required for https access to localvc. This config must be set for build agents and localvc. build-agent-git-password: buildjob_password # Replace with more secure credentials for production. Required for https access to localvc. This config must be set for build agents and localvc. You can also use an ssh key - + use-version-control-access-token: true + show-clone-url-without-token: true diff --git a/src/main/resources/config/liquibase/changelog/20240802091201_changelog.xml b/src/main/resources/config/liquibase/changelog/20240802091201_changelog.xml new file mode 100644 index 000000000000..1f4b32e3b191 --- /dev/null +++ b/src/main/resources/config/liquibase/changelog/20240802091201_changelog.xml @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/main/resources/config/liquibase/master.xml b/src/main/resources/config/liquibase/master.xml index 841c3b42900e..c5be79948d2f 100644 --- a/src/main/resources/config/liquibase/master.xml +++ b/src/main/resources/config/liquibase/master.xml @@ -20,6 +20,7 @@ + diff --git a/src/main/webapp/app/core/auth/account.service.ts b/src/main/webapp/app/core/auth/account.service.ts index 4c86a5121320..81e0c074557b 100644 --- a/src/main/webapp/app/core/auth/account.service.ts +++ b/src/main/webapp/app/core/auth/account.service.ts @@ -1,6 +1,6 @@ import { Injectable } from '@angular/core'; import { SessionStorageService } from 'ngx-webstorage'; -import { HttpClient, HttpResponse } from '@angular/common/http'; +import { HttpClient, HttpParams, HttpResponse } from '@angular/common/http'; import { BehaviorSubject, Observable, lastValueFrom, of } from 'rxjs'; import { catchError, distinctUntilChanged, map } from 'rxjs/operators'; import { Course } from 'app/entities/course.model'; @@ -139,7 +139,7 @@ export class AccountService implements IAccountService { if (this.versionControlAccessTokenRequired === undefined) { this.profileService.getProfileInfo().subscribe((profileInfo) => { - this.versionControlAccessTokenRequired = profileInfo.versionControlAccessToken ?? false; + this.versionControlAccessTokenRequired = profileInfo.useVersionControlAccessToken ?? false; }); } @@ -348,4 +348,26 @@ export class AccountService implements IAccountService { } return this.http.delete('api/users/sshpublickey'); } + + /** + * Sends a request to the server to obtain the VCS access token for a specific participation. + * Users can use this access token to clone the repository belonging to a participation. + * + * @param participationId The participation for which the VCS access token is requested + */ + getVcsAccessToken(participationId: number): Observable> { + const params = new HttpParams().set('participationId', participationId); + return this.http.get('api/users/vcsToken', { observe: 'response', params, responseType: 'text' as 'json' }); + } + + /** + * Sends a request to the server, to create a VCS access token for a specific participation. + * Users can use this access token to clone the repository belonging to a participation. + * + * @param participationId The participation for which the VCS access token should get created + */ + createVcsAccessToken(participationId: number): Observable> { + const params = new HttpParams().set('participationId', participationId); + return this.http.put('api/users/vcsToken', null, { observe: 'response', params, responseType: 'text' as 'json' }); + } } diff --git a/src/main/webapp/app/entities/participation/programming-exercise-student-participation.model.ts b/src/main/webapp/app/entities/participation/programming-exercise-student-participation.model.ts index 6a6700137ece..e68d0b18f516 100644 --- a/src/main/webapp/app/entities/participation/programming-exercise-student-participation.model.ts +++ b/src/main/webapp/app/entities/participation/programming-exercise-student-participation.model.ts @@ -10,6 +10,7 @@ export class ProgrammingExerciseStudentParticipation extends StudentParticipatio // helper attribute public buildPlanUrl?: string; public userIndependentRepositoryUri?: string; + public vcsAccessToken?: string; constructor() { super(ParticipationType.PROGRAMMING); diff --git a/src/main/webapp/app/exam/participate/summary/exercises/programming-exam-summary/programming-exam-summary.component.html b/src/main/webapp/app/exam/participate/summary/exercises/programming-exam-summary/programming-exam-summary.component.html index b528aa8b32e4..c483a168cd8a 100644 --- a/src/main/webapp/app/exam/participate/summary/exercises/programming-exam-summary/programming-exam-summary.component.html +++ b/src/main/webapp/app/exam/participate/summary/exercises/programming-exam-summary/programming-exam-summary.component.html @@ -10,6 +10,7 @@
[smallButtons]="true" [repositoryUri]="participation?.userIndependentRepositoryUri || ''" [routerLinkForRepositoryView]="routerLink + '/exercises/' + exercise.id + '/repository/' + participation.id" + [participations]="[participation]" />
diff --git a/src/main/webapp/app/shared/components/code-button/code-button.component.html b/src/main/webapp/app/shared/components/code-button/code-button.component.html index 699b243f8559..6cbcb0d59b52 100644 --- a/src/main/webapp/app/shared/components/code-button/code-button.component.html +++ b/src/main/webapp/app/shared/components/code-button/code-button.component.html @@ -30,11 +30,16 @@
{{ cloneHeadline | artemisTranslate }}
@if (sshEnabled) {
} diff --git a/src/main/webapp/app/shared/components/code-button/code-button.component.ts b/src/main/webapp/app/shared/components/code-button/code-button.component.ts index 2e3225674e79..5cf1448bd580 100644 --- a/src/main/webapp/app/shared/components/code-button/code-button.component.ts +++ b/src/main/webapp/app/shared/components/code-button/code-button.component.ts @@ -4,6 +4,7 @@ import { FeatureToggle } from 'app/shared/feature-toggle/feature-toggle.service' import { ExternalCloningService } from 'app/exercises/programming/shared/service/external-cloning.service'; import { TranslateService } from '@ngx-translate/core'; import { AccountService } from 'app/core/auth/account.service'; +import { HttpErrorResponse, HttpResponse } from '@angular/common/http'; import { User } from 'app/core/user/user.model'; import { ProfileService } from 'app/shared/layouts/profiles/profile.service'; import { LocalStorageService } from 'ngx-webstorage'; @@ -36,14 +37,16 @@ export class CodeButtonComponent implements OnInit, OnChanges { exercise?: ProgrammingExercise; useSsh = false; + useToken = false; setupSshKeysUrl?: string; sshEnabled = false; sshTemplateUrl?: string; repositoryPassword?: string; versionControlUrl: string; - versionControlAccessTokenRequired?: boolean; + useVersionControlAccessToken?: boolean; localVCEnabled = false; gitlabVCEnabled = false; + showCloneUrlWithoutToken = true; user: User; cloneHeadline: string; @@ -66,9 +69,12 @@ export class CodeButtonComponent implements OnInit, OnChanges { ) {} ngOnInit() { - this.accountService.identity().then((user) => { - this.user = user!; - }); + this.accountService + .identity() + .then((user) => { + this.user = user!; + }) + .then(() => this.loadVcsAccessTokens()); // Get ssh information from the user this.profileService.getProfileInfo().subscribe((profileInfo) => { @@ -79,8 +85,9 @@ export class CodeButtonComponent implements OnInit, OnChanges { if (profileInfo.versionControlUrl) { this.versionControlUrl = profileInfo.versionControlUrl; } - - this.versionControlAccessTokenRequired = profileInfo.versionControlAccessToken; + this.useVersionControlAccessToken = profileInfo.useVersionControlAccessToken ?? false; + this.showCloneUrlWithoutToken = profileInfo.showCloneUrlWithoutToken ?? true; + this.useToken = !this.showCloneUrlWithoutToken; this.localVCEnabled = profileInfo.activeProfiles.includes(PROFILE_LOCALVC); this.gitlabVCEnabled = profileInfo.activeProfiles.includes(PROFILE_GITLAB); if (this.localVCEnabled) { @@ -94,11 +101,6 @@ export class CodeButtonComponent implements OnInit, OnChanges { this.localStorage.observe('useSsh').subscribe((useSsh) => (this.useSsh = useSsh || false)); } - public setUseSSH(useSsh: boolean) { - this.useSsh = useSsh; - this.localStorage.store('useSsh', this.useSsh); - } - ngOnChanges() { if (this.participations?.length) { const shouldPreferPractice = this.participationService.shouldPreferPractice(this.exercise); @@ -110,6 +112,25 @@ export class CodeButtonComponent implements OnInit, OnChanges { } else if (this.repositoryUri) { this.cloneHeadline = 'artemisApp.exerciseActions.cloneExerciseRepository'; } + this.loadVcsAccessTokens(); + } + + public useSshUrl() { + this.useSsh = true; + this.useToken = false; + this.localStorage.store('useSsh', this.useSsh); + } + + public useHttpsUrlWithToken() { + this.useSsh = false; + this.useToken = true; + this.localStorage.store('useSsh', this.useSsh); + } + + public useHttpsUrlWithoutToken() { + this.useSsh = false; + this.useToken = false; + this.localStorage.store('useSsh', this.useSsh); } private getRepositoryUri() { @@ -120,14 +141,63 @@ export class CodeButtonComponent implements OnInit, OnChanges { if (this.useSsh && this.sshEnabled && this.sshTemplateUrl) { return this.getSshCloneUrl(this.getRepositoryUri()) || this.getRepositoryUri(); } - if (this.isTeamParticipation) { return this.addCredentialsToHttpUrl(this.repositoryUriForTeam(this.getRepositoryUri()), insertPlaceholder); } - return this.addCredentialsToHttpUrl(this.getRepositoryUri(), insertPlaceholder); } + loadVcsAccessTokens() { + if (this.useVersionControlAccessToken && this.localVCEnabled) { + this.participations?.forEach((participation) => { + if (participation?.id && !participation.vcsAccessToken) { + this.loadVcsAccessToken(participation); + } + }); + if (this.activeParticipation?.vcsAccessToken) { + this.user.vcsAccessToken = this.activeParticipation?.vcsAccessToken; + } + } + } + + /** + * Loads the vcsAccessToken for a participation from the server. If none exists, sens a request to create one + */ + loadVcsAccessToken(participation: ProgrammingExerciseStudentParticipation) { + this.accountService.getVcsAccessToken(participation!.id!).subscribe({ + next: (res: HttpResponse) => { + if (res.body) { + participation.vcsAccessToken = res.body; + if (this.activeParticipation?.id == participation.id) { + this.user.vcsAccessToken = res.body; + } + } + }, + error: (error: HttpErrorResponse) => { + if (error.status == 404) { + this.createNewVcsAccessToken(participation); + } + }, + }); + } + + /** + * Sends the request to create a new + */ + createNewVcsAccessToken(participation: ProgrammingExerciseStudentParticipation) { + this.accountService.createVcsAccessToken(participation!.id!).subscribe({ + next: (res: HttpResponse) => { + if (res.body) { + participation.vcsAccessToken = res.body; + if (this.activeParticipation?.id == participation.id) { + this.user.vcsAccessToken = res.body; + } + } + }, + error: () => {}, + }); + } + /** * Add the credentials to the http url, if possible. * The token will be added if @@ -138,7 +208,7 @@ export class CodeButtonComponent implements OnInit, OnChanges { * @param insertPlaceholder if true, instead of the actual token, '**********' is used (e.g. to prevent leaking the token during a screen-share) */ private addCredentialsToHttpUrl(url: string, insertPlaceholder = false): string { - const includeToken = this.versionControlAccessTokenRequired && this.user.vcsAccessToken; + const includeToken = this.useVersionControlAccessToken && this.user.vcsAccessToken && this.useToken; const token = insertPlaceholder ? '**********' : this.user.vcsAccessToken; const credentials = `://${this.user.login}${includeToken ? `:${token}` : ''}@`; if (!url.includes('@')) { @@ -213,5 +283,8 @@ export class CodeButtonComponent implements OnInit, OnChanges { this.isPracticeMode = !this.isPracticeMode; this.activeParticipation = this.participationService.getSpecificStudentParticipation(this.participations!, this.isPracticeMode)!; this.cloneHeadline = this.isPracticeMode ? 'artemisApp.exerciseActions.clonePracticeRepository' : 'artemisApp.exerciseActions.cloneRatedRepository'; + if (this.activeParticipation.vcsAccessToken) { + this.user.vcsAccessToken = this.activeParticipation.vcsAccessToken; + } } } diff --git a/src/main/webapp/app/shared/layouts/profiles/profile-info.model.ts b/src/main/webapp/app/shared/layouts/profiles/profile-info.model.ts index acebf864b323..18c335e0079f 100644 --- a/src/main/webapp/app/shared/layouts/profiles/profile-info.model.ts +++ b/src/main/webapp/app/shared/layouts/profiles/profile-info.model.ts @@ -33,7 +33,8 @@ export class ProfileInfo { public accountName?: string; public versionControlUrl?: string; public versionControlName?: string; - public versionControlAccessToken?: boolean; + public useVersionControlAccessToken?: boolean; + public showCloneUrlWithoutToken?: boolean; public continuousIntegrationName?: string; public programmingLanguageFeatures: ProgrammingLanguageFeature[]; public saml2?: Saml2Config; diff --git a/src/main/webapp/app/shared/layouts/profiles/profile.service.ts b/src/main/webapp/app/shared/layouts/profiles/profile.service.ts index 1767be3bb8e9..ce197b034a47 100644 --- a/src/main/webapp/app/shared/layouts/profiles/profile.service.ts +++ b/src/main/webapp/app/shared/layouts/profiles/profile.service.ts @@ -70,7 +70,8 @@ export class ProfileService { profileInfo.accountName = data.accountName; profileInfo.versionControlUrl = data.versionControlUrl; profileInfo.versionControlName = data.versionControlName; - profileInfo.versionControlAccessToken = data.versionControlAccessToken; + profileInfo.showCloneUrlWithoutToken = data.showCloneUrlWithoutToken; + profileInfo.useVersionControlAccessToken = data.useVersionControlAccessToken; profileInfo.continuousIntegrationName = data.continuousIntegrationName; profileInfo.programmingLanguageFeatures = data.programmingLanguageFeatures; profileInfo.textAssessmentAnalyticsEnabled = data.textAssessmentAnalyticsEnabled; diff --git a/src/test/java/de/tum/in/www1/artemis/authentication/UserJenkinsGitlabIntegrationTest.java b/src/test/java/de/tum/in/www1/artemis/authentication/UserJenkinsGitlabIntegrationTest.java index d248ef9d3684..de81435c7678 100644 --- a/src/test/java/de/tum/in/www1/artemis/authentication/UserJenkinsGitlabIntegrationTest.java +++ b/src/test/java/de/tum/in/www1/artemis/authentication/UserJenkinsGitlabIntegrationTest.java @@ -165,18 +165,18 @@ void createUser_asAdmin_isSuccessful() throws Exception { @WithMockUser(username = "admin", roles = "ADMIN") void createInternalUser_asAdmin_with_vcsAccessToken_isSuccessful() throws Exception { gitlabRequestMockProvider.mockCreationOfUser("batman"); - ReflectionTestUtils.setField(gitLabPersonalAccessTokenManagementService, "versionControlAccessToken", true); + ReflectionTestUtils.setField(gitLabPersonalAccessTokenManagementService, "useVersionControlAccessToken", true); userTestService.createInternalUser_asAdmin_isSuccessful(); - ReflectionTestUtils.setField(gitLabPersonalAccessTokenManagementService, "versionControlAccessToken", false); + ReflectionTestUtils.setField(gitLabPersonalAccessTokenManagementService, "useVersionControlAccessToken", false); } @Test @WithMockUser(username = "admin", roles = "ADMIN") void createInternalUserWithoutRoles_isSuccessful() throws Exception { gitlabRequestMockProvider.mockCreationOfUser("batman"); - ReflectionTestUtils.setField(gitLabPersonalAccessTokenManagementService, "versionControlAccessToken", true); + ReflectionTestUtils.setField(gitLabPersonalAccessTokenManagementService, "useVersionControlAccessToken", true); userTestService.createInternalUserWithoutRoles_asAdmin_isSuccessful(); - ReflectionTestUtils.setField(gitLabPersonalAccessTokenManagementService, "versionControlAccessToken", false); + ReflectionTestUtils.setField(gitLabPersonalAccessTokenManagementService, "useVersionControlAccessToken", false); } @Test diff --git a/src/test/java/de/tum/in/www1/artemis/authentication/UserLocalVcIntegrationTest.java b/src/test/java/de/tum/in/www1/artemis/authentication/UserLocalVcIntegrationTest.java new file mode 100644 index 000000000000..b419cbf57260 --- /dev/null +++ b/src/test/java/de/tum/in/www1/artemis/authentication/UserLocalVcIntegrationTest.java @@ -0,0 +1,34 @@ +package de.tum.in.www1.artemis.authentication; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.test.context.support.WithMockUser; + +import de.tum.in.www1.artemis.AbstractSpringIntegrationLocalCILocalVCTest; +import de.tum.in.www1.artemis.user.UserTestService; + +class UserLocalVcIntegrationTest extends AbstractSpringIntegrationLocalCILocalVCTest { + + private static final String TEST_PREFIX = "userlvc"; // shorter prefix as user's name is limited to 50 chars + + @Autowired + private UserTestService userTestService; + + @BeforeEach + void setUp() throws Exception { + userTestService.setup(TEST_PREFIX, this); + } + + @AfterEach + void teardown() throws Exception { + userTestService.tearDown(); + } + + @Test + @WithMockUser(username = TEST_PREFIX + "student1", roles = "USER") + void addAndDeleteSshPublicKeyByUser() throws Exception { + userTestService.addAndDeleteSshPublicKey(); + } +} diff --git a/src/test/java/de/tum/in/www1/artemis/exercise/programming/ProgrammingExerciseResultTestService.java b/src/test/java/de/tum/in/www1/artemis/exercise/programming/ProgrammingExerciseResultTestService.java index 720c001b531d..9982da320236 100644 --- a/src/test/java/de/tum/in/www1/artemis/exercise/programming/ProgrammingExerciseResultTestService.java +++ b/src/test/java/de/tum/in/www1/artemis/exercise/programming/ProgrammingExerciseResultTestService.java @@ -51,6 +51,7 @@ import de.tum.in.www1.artemis.participation.ParticipationFactory; import de.tum.in.www1.artemis.participation.ParticipationUtilService; import de.tum.in.www1.artemis.repository.FeedbackRepository; +import de.tum.in.www1.artemis.repository.ParticipationVCSAccessTokenRepository; import de.tum.in.www1.artemis.repository.ProgrammingExerciseBuildConfigRepository; import de.tum.in.www1.artemis.repository.ProgrammingExerciseRepository; import de.tum.in.www1.artemis.repository.ProgrammingExerciseStudentParticipationRepository; @@ -127,6 +128,9 @@ public class ProgrammingExerciseResultTestService { @Autowired private ParticipationUtilService participationUtilService; + @Autowired + private ParticipationVCSAccessTokenRepository participationVCSAccessTokenRepository; + private Course course; private ProgrammingExercise programmingExercise; @@ -178,7 +182,11 @@ public void tearDown() { // Test public void shouldUpdateFeedbackInSemiAutomaticResult(AbstractBuildResultNotificationDTO buildResultNotification, String loginName) throws Exception { // Make sure we only have one participation - participationRepository.deleteAll(participationRepository.findByExerciseId(programmingExercise.getId())); + var participations = participationRepository.findByExerciseId(programmingExercise.getId()); + for (ProgrammingExerciseStudentParticipation participation : participations) { + participationVCSAccessTokenRepository.deleteByParticipationId(participation.getId()); + } + participationRepository.deleteAll(participations); programmingExerciseStudentParticipation = participationUtilService.addStudentParticipationForProgrammingExercise(programmingExercise, loginName); // Add a student submission with two manual results and a semi automatic result diff --git a/src/test/java/de/tum/in/www1/artemis/localvcci/LocalCIIntegrationTest.java b/src/test/java/de/tum/in/www1/artemis/localvcci/LocalCIIntegrationTest.java index 038b365a1076..82d4522cf664 100644 --- a/src/test/java/de/tum/in/www1/artemis/localvcci/LocalCIIntegrationTest.java +++ b/src/test/java/de/tum/in/www1/artemis/localvcci/LocalCIIntegrationTest.java @@ -62,6 +62,7 @@ import de.tum.in.www1.artemis.repository.BuildJobRepository; import de.tum.in.www1.artemis.repository.ProgrammingSubmissionTestRepository; import de.tum.in.www1.artemis.service.BuildLogEntryService; +import de.tum.in.www1.artemis.service.ParticipationVcsAccessTokenService; import de.tum.in.www1.artemis.service.connectors.localci.dto.ResultBuildJob; import de.tum.in.www1.artemis.service.connectors.localvc.LocalVCServletService; import de.tum.in.www1.artemis.util.LocalRepository; @@ -78,6 +79,9 @@ class LocalCIIntegrationTest extends AbstractLocalCILocalVCIntegrationTest { @Autowired private ProgrammingSubmissionTestRepository programmingSubmissionRepository; + @Autowired + private ParticipationVcsAccessTokenService participationVcsAccessTokenService; + @Autowired private BuildLogEntryService buildLogEntryService; @@ -190,6 +194,7 @@ void testNoParticipationWhenPushingToTestsRepository() throws Exception { String expectedErrorMessage = "Could not find participation for repository"; // student participation + participationVcsAccessTokenService.deleteByParticipationId(studentParticipation.getId()); programmingExerciseStudentParticipationRepository.delete(studentParticipation); assertThatExceptionOfType(VersionControlException.class) .isThrownBy(() -> localVCServletService.processNewPush(commitHash, studentAssignmentRepository.originGit.getRepository())) diff --git a/src/test/java/de/tum/in/www1/artemis/localvcci/LocalVCInfoContributorTest.java b/src/test/java/de/tum/in/www1/artemis/localvcci/LocalVCInfoContributorTest.java index e08769b1e46d..c078f3e27a98 100644 --- a/src/test/java/de/tum/in/www1/artemis/localvcci/LocalVCInfoContributorTest.java +++ b/src/test/java/de/tum/in/www1/artemis/localvcci/LocalVCInfoContributorTest.java @@ -1,18 +1,21 @@ package de.tum.in.www1.artemis.localvcci; +import static de.tum.in.www1.artemis.config.Constants.PROFILE_BUILDAGENT; import static de.tum.in.www1.artemis.config.Constants.PROFILE_LOCALVC; import static org.assertj.core.api.Assertions.assertThat; import org.junit.jupiter.api.Test; import org.springframework.boot.actuate.info.Info; -import org.springframework.context.annotation.Profile; +import org.springframework.boot.test.mock.mockito.SpyBean; +import org.springframework.test.context.ActiveProfiles; import de.tum.in.www1.artemis.service.connectors.localvc.LocalVCInfoContributor; -@Profile(PROFILE_LOCALVC) +@ActiveProfiles({ "artemis", PROFILE_LOCALVC, PROFILE_BUILDAGENT }) class LocalVCInfoContributorTest { - LocalVCInfoContributor localVCInfoContributor; + @SpyBean + private LocalVCInfoContributor localVCInfoContributor; @Test void testContribute() { @@ -25,7 +28,6 @@ void testContribute() { } Info info = builder.build(); - assertThat((Boolean) info.getDetails().get("versionControlAccessToken")).isFalse(); - + assertThat((Boolean) info.getDetails().get("useVersionControlAccessToken")).isFalse(); } } diff --git a/src/test/java/de/tum/in/www1/artemis/localvcci/LocalVCIntegrationTest.java b/src/test/java/de/tum/in/www1/artemis/localvcci/LocalVCIntegrationTest.java index 36f604ec52ac..83d96942ce05 100644 --- a/src/test/java/de/tum/in/www1/artemis/localvcci/LocalVCIntegrationTest.java +++ b/src/test/java/de/tum/in/www1/artemis/localvcci/LocalVCIntegrationTest.java @@ -10,6 +10,7 @@ import java.net.URISyntaxException; import java.nio.file.Files; import java.nio.file.Path; +import java.time.ZonedDateTime; import java.util.Optional; import javax.naming.InvalidNameException; @@ -93,6 +94,37 @@ void testFetchPush_repositoryDoesNotExist() throws IOException, GitAPIException, someRepository.resetLocalRepo(); } + @Test + void testFetchPush_usingVcsAccessToken() { + var programmingParticipation = localVCLocalCITestService.createParticipation(programmingExercise, student1Login); + var student = userUtilService.getUserByLogin(student1Login); + var participationVcsAccessToken = localVCLocalCITestService.getParticipationVcsAccessToken(student.getId(), programmingParticipation.getId()); + var token = participationVcsAccessToken.getVcsAccessToken(); + programmingExerciseRepository.save(programmingExercise); + + // Fetch from and push to the remote repository with participation VCS access token + localVCLocalCITestService.testFetchSuccessful(assignmentRepository.localGit, student1Login, token, projectKey1, assignmentRepositorySlug); + localVCLocalCITestService.testFetchSuccessful(assignmentRepository.localGit, student1Login, token, projectKey1, assignmentRepositorySlug); + + // Fetch from and push to the remote repository with user VCS access token + var studentWithToken = userUtilService.setUserVcsAccessTokenAndExpiryDateAndSave(student, token, ZonedDateTime.now().plusDays(1)); + localVCLocalCITestService.testFetchSuccessful(assignmentRepository.localGit, student1Login, token, projectKey1, assignmentRepositorySlug); + localVCLocalCITestService.testFetchSuccessful(assignmentRepository.localGit, student1Login, token, projectKey1, assignmentRepositorySlug); + + // Try to fetch and push, when token is removed and re-added, which makes the previous token invalid + userUtilService.deleteUserVcsAccessToken(studentWithToken); + localVCLocalCITestService.deleteParticipationVcsAccessToken(programmingParticipation.getId()); + localVCLocalCITestService.createParticipationVcsAccessToken(student, programmingParticipation.getId()); + localVCLocalCITestService.testFetchReturnsError(assignmentRepository.localGit, student1Login, token, projectKey1, assignmentRepositorySlug, NOT_AUTHORIZED); + localVCLocalCITestService.testFetchReturnsError(assignmentRepository.localGit, student1Login, token, projectKey1, assignmentRepositorySlug, NOT_AUTHORIZED); + + // Try to fetch and push with removed participation + localVCLocalCITestService.deleteParticipationVcsAccessToken(programmingParticipation.getId()); + localVCLocalCITestService.deleteParticipation(programmingParticipation); + localVCLocalCITestService.testFetchReturnsError(assignmentRepository.localGit, student1Login, token, projectKey1, assignmentRepositorySlug, NOT_AUTHORIZED); + localVCLocalCITestService.testFetchReturnsError(assignmentRepository.localGit, student1Login, token, projectKey1, assignmentRepositorySlug, NOT_AUTHORIZED); + } + @Test void testFetchPush_wrongCredentials() throws InvalidNameException { var student1 = new LdapUserDto().login(TEST_PREFIX + "student1"); diff --git a/src/test/java/de/tum/in/www1/artemis/localvcci/LocalVCLocalCIParticipationIntegrationTest.java b/src/test/java/de/tum/in/www1/artemis/localvcci/LocalVCLocalCIParticipationIntegrationTest.java index 8cad606841f0..190d02485212 100644 --- a/src/test/java/de/tum/in/www1/artemis/localvcci/LocalVCLocalCIParticipationIntegrationTest.java +++ b/src/test/java/de/tum/in/www1/artemis/localvcci/LocalVCLocalCIParticipationIntegrationTest.java @@ -57,6 +57,10 @@ void testStartParticipation() throws Exception { LocalVCRepositoryUri studentAssignmentRepositoryUri = new LocalVCRepositoryUri(projectKey, projectKey.toLowerCase() + "-" + TEST_PREFIX + "student1", localVCBaseUrl); assertThat(studentAssignmentRepositoryUri.getLocalRepositoryPath(localVCBasePath)).exists(); + var vcsAccessToken = request.get("/api/users/vcsToken?participationId=" + participation.getId(), HttpStatus.OK, String.class); + assertThat(vcsAccessToken).isNotNull(); + assertThat(vcsAccessToken).startsWith("vcpat"); + templateRepository.resetLocalRepo(); } } diff --git a/src/test/java/de/tum/in/www1/artemis/localvcci/LocalVCLocalCITestService.java b/src/test/java/de/tum/in/www1/artemis/localvcci/LocalVCLocalCITestService.java index aaabc2bf53ff..c5fb018478c5 100644 --- a/src/test/java/de/tum/in/www1/artemis/localvcci/LocalVCLocalCITestService.java +++ b/src/test/java/de/tum/in/www1/artemis/localvcci/LocalVCLocalCITestService.java @@ -56,13 +56,16 @@ import de.tum.in.www1.artemis.domain.ProgrammingExerciseTestCase; import de.tum.in.www1.artemis.domain.ProgrammingSubmission; import de.tum.in.www1.artemis.domain.Result; +import de.tum.in.www1.artemis.domain.User; import de.tum.in.www1.artemis.domain.enumeration.Visibility; +import de.tum.in.www1.artemis.domain.participation.ParticipationVCSAccessToken; import de.tum.in.www1.artemis.domain.participation.ProgrammingExerciseStudentParticipation; import de.tum.in.www1.artemis.participation.ParticipationUtilService; import de.tum.in.www1.artemis.repository.ProgrammingExerciseStudentParticipationRepository; import de.tum.in.www1.artemis.repository.ProgrammingExerciseTestCaseRepository; import de.tum.in.www1.artemis.repository.ProgrammingSubmissionTestRepository; import de.tum.in.www1.artemis.repository.ResultRepository; +import de.tum.in.www1.artemis.service.ParticipationVcsAccessTokenService; import de.tum.in.www1.artemis.service.connectors.GitService; import de.tum.in.www1.artemis.service.connectors.localvc.LocalVCRepositoryUri; import de.tum.in.www1.artemis.util.LocalRepository; @@ -85,6 +88,9 @@ public class LocalVCLocalCITestService { @Autowired private ProgrammingExerciseStudentParticipationRepository programmingExerciseStudentParticipationRepository; + @Autowired + private ParticipationVcsAccessTokenService participationVcsAccessTokenService; + @Autowired private ResultRepository resultRepository; @@ -94,7 +100,7 @@ public class LocalVCLocalCITestService { @Value("${artemis.version-control.default-branch:main}") protected String defaultBranch; - private final int DEFAULT_AWAITILITY_TIMEOUT_IN_SECONDS = 10; + private static final int DEFAULT_AWAITILITY_TIMEOUT_IN_SECONDS = 10; private static final Logger log = LoggerFactory.getLogger(LocalVCLocalCITestService.class); @@ -529,8 +535,21 @@ private void performFetch(Git repositoryHandle, String username, String password * @param repositorySlug the repository slug of the repository. */ public void testPushSuccessful(Git repositoryHandle, String username, String projectKey, String repositorySlug) { + testPushSuccessful(repositoryHandle, username, USER_PASSWORD, projectKey, repositorySlug); + } + + /** + * Perform a push operation and fail if there was an exception. + * + * @param repositoryHandle the Git object for the repository. + * @param username the username of the user that tries to push to the repository. + * @param password the password or token of the user + * @param projectKey the project key of the repository. + * @param repositorySlug the repository slug of the repository. + */ + public void testPushSuccessful(Git repositoryHandle, String username, String password, String projectKey, String repositorySlug) { try { - performPush(repositoryHandle, username, USER_PASSWORD, projectKey, repositorySlug); + performPush(repositoryHandle, username, password, projectKey, repositorySlug); } catch (GitAPIException e) { fail("Pushing was not successful: " + e.getMessage()); @@ -640,4 +659,44 @@ public void verifyRepositoryFoldersExist(ProgrammingExercise programmingExercise LocalVCRepositoryUri testsRepositoryUri = new LocalVCRepositoryUri(programmingExercise.getTestRepositoryUri()); assertThat(testsRepositoryUri.getLocalRepositoryPath(localVCBasePath)).exists(); } + + /** + * Gets the participationVcsAccessToken belonging to a user and a participation + * + * @param userId The user's id + * @param programmingParticipationId The participation's id + * + * @return the participationVcsAccessToken of the user for the given participationId + */ + public ParticipationVCSAccessToken getParticipationVcsAccessToken(Long userId, Long programmingParticipationId) { + return participationVcsAccessTokenService.findByUserIdAndParticipationIdOrElseThrow(userId, programmingParticipationId); + } + + /** + * Deletes the participationVcsAccessToken for a participation + * + * @param participationId The participationVcsAccessToken's participationId + */ + public void deleteParticipationVcsAccessToken(long participationId) { + participationVcsAccessTokenService.deleteByParticipationId(participationId); + } + + /** + * Creates the participationVcsAccessToken for a user and a participation + * + * @param user The user for which the token should get created + * @param participationId The participationVcsAccessToken's participationId + */ + public void createParticipationVcsAccessToken(User user, long participationId) { + participationVcsAccessTokenService.createVcsAccessTokenForUserAndParticipationIdOrElseThrow(user, participationId); + } + + /** + * Deletes a programmingParticipation + * + * @param programmingParticipation The participation to delete + */ + public void deleteParticipation(ProgrammingExerciseStudentParticipation programmingParticipation) { + programmingExerciseStudentParticipationRepository.delete(programmingParticipation); + } } diff --git a/src/test/java/de/tum/in/www1/artemis/participation/ParticipationUtilService.java b/src/test/java/de/tum/in/www1/artemis/participation/ParticipationUtilService.java index 1e464250397a..74e28ad44ea6 100644 --- a/src/test/java/de/tum/in/www1/artemis/participation/ParticipationUtilService.java +++ b/src/test/java/de/tum/in/www1/artemis/participation/ParticipationUtilService.java @@ -66,6 +66,7 @@ import de.tum.in.www1.artemis.repository.TeamRepository; import de.tum.in.www1.artemis.repository.TextSubmissionRepository; import de.tum.in.www1.artemis.service.ParticipationService; +import de.tum.in.www1.artemis.service.ParticipationVcsAccessTokenService; import de.tum.in.www1.artemis.service.UriService; import de.tum.in.www1.artemis.service.connectors.ci.ContinuousIntegrationService; import de.tum.in.www1.artemis.service.connectors.vcs.VersionControlService; @@ -86,6 +87,9 @@ public class ParticipationUtilService { @Autowired private StudentParticipationRepository studentParticipationRepo; + @Autowired + private ParticipationVcsAccessTokenService participationVCSAccessTokenService; + @Autowired private ExerciseRepository exerciseRepo; @@ -313,7 +317,7 @@ public ProgrammingExerciseStudentParticipation addStudentParticipationForProgram final var repoName = (exercise.getProjectKey() + "-" + login).toLowerCase(); participation.setRepositoryUri(String.format("http://some.test.url/scm/%s/%s.git", exercise.getProjectKey(), repoName)); participation = programmingExerciseStudentParticipationRepo.save(participation); - + participationVCSAccessTokenService.createParticipationVCSAccessToken(userUtilService.getUserByLogin(login), participation); return (ProgrammingExerciseStudentParticipation) studentParticipationRepo.findWithEagerLegalSubmissionsAndResultsAssessorsById(participation.getId()).orElseThrow(); } diff --git a/src/test/java/de/tum/in/www1/artemis/service/connectors/gitlab/GitLabPersonalAccessTokenManagementServiceTest.java b/src/test/java/de/tum/in/www1/artemis/service/connectors/gitlab/GitLabPersonalAccessTokenManagementServiceTest.java index 648cd0b062a0..bcff9e9f0c89 100644 --- a/src/test/java/de/tum/in/www1/artemis/service/connectors/gitlab/GitLabPersonalAccessTokenManagementServiceTest.java +++ b/src/test/java/de/tum/in/www1/artemis/service/connectors/gitlab/GitLabPersonalAccessTokenManagementServiceTest.java @@ -52,12 +52,12 @@ class GitLabPersonalAccessTokenManagementServiceTest extends AbstractSpringInteg void setUp() { gitlabRequestMockProvider.enableMockingOfRequests(); userUtilService.addUsers(TEST_PREFIX, 1, 0, 0, 0); - ReflectionTestUtils.setField(gitLabPersonalAccessTokenManagementService, "versionControlAccessToken", true); + ReflectionTestUtils.setField(gitLabPersonalAccessTokenManagementService, "useVersionControlAccessToken", true); } @AfterEach void teardown() throws Exception { - ReflectionTestUtils.setField(gitLabPersonalAccessTokenManagementService, "versionControlAccessToken", false); + ReflectionTestUtils.setField(gitLabPersonalAccessTokenManagementService, "useVersionControlAccessToken", false); gitlabRequestMockProvider.reset(); } diff --git a/src/test/java/de/tum/in/www1/artemis/user/UserTestService.java b/src/test/java/de/tum/in/www1/artemis/user/UserTestService.java index 76100200d4ca..394200342127 100644 --- a/src/test/java/de/tum/in/www1/artemis/user/UserTestService.java +++ b/src/test/java/de/tum/in/www1/artemis/user/UserTestService.java @@ -17,7 +17,9 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.cache.CacheManager; import org.springframework.data.domain.Pageable; +import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; import org.springframework.stereotype.Service; import org.springframework.util.LinkedMultiValueMap; @@ -808,6 +810,25 @@ public void initializeUserExternal() throws Exception { assertThat(currentUser.isInternal()).isFalse(); } + // Test + public void addAndDeleteSshPublicKey() throws Exception { + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.TEXT_PLAIN); + + // adding invalid key should fail + String invalidSshKey = "invalid key"; + request.putWithResponseBody("/api/users/sshpublickey", invalidSshKey, String.class, HttpStatus.BAD_REQUEST, true); + + // adding valid key should work correctly + String validSshKey = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIEbgjoSpKnry5yuMiWh/uwhMG2Jq5Sh8Uw9vz+39or2i email@abc.de"; + request.putWithResponseBody("/api/users/sshpublickey", validSshKey, String.class, HttpStatus.OK, true); + assertThat(userRepository.getUser().getSshPublicKey()).isEqualTo(validSshKey); + + // deleting the key shoul work correctly + request.delete("/api/users/sshpublickey", HttpStatus.OK); + assertThat(userRepository.getUser().getSshPublicKey()).isEqualTo(null); + } + public UserRepository getUserRepository() { return userRepository; } diff --git a/src/test/java/de/tum/in/www1/artemis/user/UserUtilService.java b/src/test/java/de/tum/in/www1/artemis/user/UserUtilService.java index 95da9ce1c4c9..a55828288a85 100644 --- a/src/test/java/de/tum/in/www1/artemis/user/UserUtilService.java +++ b/src/test/java/de/tum/in/www1/artemis/user/UserUtilService.java @@ -3,6 +3,7 @@ import static de.tum.in.www1.artemis.user.UserFactory.USER_PASSWORD; import static org.assertj.core.api.Assertions.assertThat; +import java.time.ZonedDateTime; import java.util.ArrayList; import java.util.List; import java.util.Set; @@ -184,6 +185,31 @@ public User setRegistrationNumberOfUserAndSave(User user, String registrationNum return userRepo.save(user); } + /** + * Updates and saves the user's vcsAccessToken and its expiry date + * + * @param user The User to update + * @param vcsAccessToken The userVcsAccessToken to set + * @param expiryDate The tokens expiry date + * @return The updated User + */ + public User setUserVcsAccessTokenAndExpiryDateAndSave(User user, String vcsAccessToken, ZonedDateTime expiryDate) { + user.setVcsAccessToken(vcsAccessToken); + user.setVcsAccessTokenExpiryDate(expiryDate); + return userRepo.save(user); + } + + /** + * Deletes the userVcsAccessToken from a user + * + * @param userWithUserToken The user whose token gets deleted + */ + public void deleteUserVcsAccessToken(User userWithUserToken) { + userWithUserToken.setVcsAccessTokenExpiryDate(null); + userWithUserToken.setVcsAccessToken(null); + userRepo.save(userWithUserToken); + } + /** * Creates and saves the given amount of Users with the given arguments. * diff --git a/src/test/java/de/tum/in/www1/artemis/util/RequestUtilService.java b/src/test/java/de/tum/in/www1/artemis/util/RequestUtilService.java index c12cfd9aaaba..4707ce1383ae 100644 --- a/src/test/java/de/tum/in/www1/artemis/util/RequestUtilService.java +++ b/src/test/java/de/tum/in/www1/artemis/util/RequestUtilService.java @@ -479,22 +479,32 @@ public R putWithMultipartFiles(String path, T paramValue, String paramNam } public R putWithResponseBody(String path, T body, Class responseType, HttpStatus expectedStatus, Map expectedResponseHeaders) throws Exception { - return putWithResponseBodyAndParams(path, body, responseType, expectedStatus, new LinkedMultiValueMap<>(), expectedResponseHeaders); + return putWithResponseBodyAndParams(path, body, responseType, expectedStatus, new LinkedMultiValueMap<>(), expectedResponseHeaders, false); + } + + public R putWithResponseBody(String path, T body, Class responseType, HttpStatus expectedStatus, boolean bodyIsString) throws Exception { + return putWithResponseBodyAndParams(path, body, responseType, expectedStatus, new LinkedMultiValueMap<>(), new HashMap<>(), bodyIsString); } public R putWithResponseBody(String path, T body, Class responseType, HttpStatus expectedStatus) throws Exception { - return putWithResponseBodyAndParams(path, body, responseType, expectedStatus, new LinkedMultiValueMap<>(), new HashMap<>()); + return putWithResponseBodyAndParams(path, body, responseType, expectedStatus, new LinkedMultiValueMap<>(), new HashMap<>(), false); } public R putWithResponseBodyAndParams(String path, T body, Class responseType, HttpStatus expectedStatus, MultiValueMap params) throws Exception { - return putWithResponseBodyAndParams(path, body, responseType, expectedStatus, params, new HashMap<>()); + return putWithResponseBodyAndParams(path, body, responseType, expectedStatus, params, new HashMap<>(), false); } public R putWithResponseBodyAndParams(String path, T body, Class responseType, HttpStatus expectedStatus, @Nullable MultiValueMap params, - Map expectedResponseHeaders) throws Exception { - String jsonBody = mapper.writeValueAsString(body); + Map expectedResponseHeaders, boolean isString) throws Exception { + String stringBody; + if (isString) { + stringBody = (String) body; + } + else { + stringBody = mapper.writeValueAsString(body); + } MvcResult res = mvc - .perform(addRequestPostProcessorIfAvailable(MockMvcRequestBuilders.put(new URI(path)).contentType(MediaType.APPLICATION_JSON).content(jsonBody).params(params))) + .perform(addRequestPostProcessorIfAvailable(MockMvcRequestBuilders.put(new URI(path)).contentType(MediaType.APPLICATION_JSON).content(stringBody).params(params))) .andExpect(status().is(expectedStatus.value())).andReturn(); restoreSecurityContext(); diff --git a/src/test/javascript/spec/component/account/ssh-user-settings.component.spec.ts b/src/test/javascript/spec/component/account/ssh-user-settings.component.spec.ts index 46a3388595da..c7b44b4f9e97 100644 --- a/src/test/javascript/spec/component/account/ssh-user-settings.component.spec.ts +++ b/src/test/javascript/spec/component/account/ssh-user-settings.component.spec.ts @@ -1,8 +1,8 @@ -import { HttpResponse } from '@angular/common/http'; +import { HttpErrorResponse, HttpResponse } from '@angular/common/http'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { AccountService } from 'app/core/auth/account.service'; import { ProfileService } from 'app/shared/layouts/profiles/profile.service'; -import { of } from 'rxjs'; +import { of, throwError } from 'rxjs'; import { User } from 'app/core/user/user.model'; import { ArtemisTestModule } from '../../test.module'; import { MockNgbModalService } from '../../helpers/mocks/service/mock-ngb-modal.service'; @@ -17,7 +17,11 @@ describe('SshUserSettingsComponent', () => { let comp: SshUserSettingsComponent; const mockKey = 'mock-key'; - let accountServiceMock: { getAuthenticationState: jest.Mock; addSshPublicKey: jest.Mock }; + let accountServiceMock: { + getAuthenticationState: jest.Mock; + addSshPublicKey: jest.Mock; + deleteSshPublicKey: jest.Mock; + }; let profileServiceMock: { getProfileInfo: jest.Mock }; let translateService: TranslateService; @@ -28,6 +32,7 @@ describe('SshUserSettingsComponent', () => { accountServiceMock = { getAuthenticationState: jest.fn(), addSshPublicKey: jest.fn(), + deleteSshPublicKey: jest.fn(), }; await TestBed.configureTestingModule({ @@ -76,4 +81,44 @@ describe('SshUserSettingsComponent', () => { expect(accountServiceMock.addSshPublicKey).toHaveBeenCalledWith('new-key'); expect(comp.editSshKey).toBeFalse(); }); + + it('should delete SSH key and disable edit mode', () => { + accountServiceMock.deleteSshPublicKey.mockReturnValue(of(new HttpResponse({ status: 200 }))); + comp.ngOnInit(); + const empty = ''; + comp.sshKey = 'new-key'; + comp.editSshKey = true; + comp.deleteSshKey(); + expect(accountServiceMock.deleteSshPublicKey).toHaveBeenCalled(); + expect(comp.editSshKey).toBeFalse(); + expect(comp.storedSshKey).toEqual(empty); + }); + + it('should not delete and save on error response', () => { + const errorResponse = new HttpErrorResponse({ status: 500, statusText: 'Server Error', error: { message: 'Error occurred' } }); + accountServiceMock.deleteSshPublicKey.mockReturnValue(throwError(() => errorResponse)); + accountServiceMock.addSshPublicKey.mockReturnValue(throwError(() => errorResponse)); + comp.ngOnInit(); + const key = 'new-key'; + comp.sshKey = key; + comp.storedSshKey = key; + comp.saveSshKey(); + comp.deleteSshKey(); + expect(comp.storedSshKey).toEqual(key); + }); + + it('should cancel editing on cancel', () => { + accountServiceMock.addSshPublicKey.mockReturnValue(of(new HttpResponse({ status: 200 }))); + comp.ngOnInit(); + const oldKey = 'old-key'; + const newKey = 'new-key'; + comp.sshKey = oldKey; + comp.editSshKey = true; + comp.saveSshKey(); + expect(comp.storedSshKey).toEqual(oldKey); + comp.editSshKey = true; + comp.sshKey = newKey; + comp.cancelEditingSshKey(); + expect(comp.storedSshKey).toEqual(oldKey); + }); }); diff --git a/src/test/javascript/spec/component/shared/code-button.component.spec.ts b/src/test/javascript/spec/component/shared/code-button.component.spec.ts index 497e334875e6..f0fff85034ca 100644 --- a/src/test/javascript/spec/component/shared/code-button.component.spec.ts +++ b/src/test/javascript/spec/component/shared/code-button.component.spec.ts @@ -19,7 +19,7 @@ import { SafeUrlPipe } from 'app/shared/pipes/safe-url.pipe'; import dayjs from 'dayjs/esm'; import { MockComponent, MockDirective, MockPipe, MockProvider } from 'ng-mocks'; import { LocalStorageService } from 'ngx-webstorage'; -import { BehaviorSubject, Subject } from 'rxjs'; +import { BehaviorSubject, Subject, of, throwError } from 'rxjs'; import { MockAccountService } from '../../helpers/mocks/service/mock-account.service'; import { MockFeatureToggleService } from '../../helpers/mocks/service/mock-feature-toggle.service'; import { MockProfileService } from '../../helpers/mocks/service/mock-profile.service'; @@ -27,6 +27,7 @@ import { MockSyncStorage } from '../../helpers/mocks/service/mock-sync-storage.s import { MockTranslateService } from '../../helpers/mocks/service/mock-translate.service'; import { ArtemisTestModule } from '../../test.module'; import { RouterTestingModule } from '@angular/router/testing'; +import { HttpErrorResponse, HttpResponse } from '@angular/common/http'; describe('CodeButtonComponent', () => { let component: CodeButtonComponent; @@ -38,6 +39,10 @@ describe('CodeButtonComponent', () => { let localStorageUseSshObserveStub: jest.SpyInstance; let localStorageUseSshObserveStubSubject: Subject; let localStorageUseSshStoreStub: jest.SpyInstance; + let getVcsAccessTokenSpy: jest.SpyInstance; + let createVcsAccessTokenSpy: jest.SpyInstance; + + const vcsToken: string = 'vcpat-xlhBs26D4F2CGlkCM59KVU8aaV9bYdX5Mg4IK6T8W3aT'; const user = { login: 'user1', guidedTourSettings: [], internal: true, vcsAccessToken: 'token' }; @@ -48,7 +53,7 @@ describe('CodeButtonComponent', () => { ['de', ''], ]), useExternal: false, - activeProfiles: [], + activeProfiles: ['localvc'], allowedMinimumOrionVersion: '', buildPlanURLTemplate: '', commitHashURLTemplate: '', @@ -63,7 +68,7 @@ describe('CodeButtonComponent', () => { sshKeysURL: 'sshKeysURL', testServer: false, versionControlUrl: 'https://gitlab.ase.in.tum.de/scm/ITCPLEASE1/itcplease1-exercise-team1.git', - versionControlAccessToken: true, + useVersionControlAccessToken: true, git: { branch: 'code-button', commit: { @@ -79,7 +84,7 @@ describe('CodeButtonComponent', () => { theiaPortalURL: 'https://theia-test.k8s.ase.cit.tum.de', }; - let participation: ProgrammingExerciseStudentParticipation = {}; + let participation: ProgrammingExerciseStudentParticipation = new ProgrammingExerciseStudentParticipation(); beforeEach(() => { TestBed.configureTestingModule({ @@ -111,6 +116,9 @@ describe('CodeButtonComponent', () => { localStorageUseSshRetrieveStub = jest.spyOn(localStorageMock, 'retrieve'); localStorageUseSshObserveStub = jest.spyOn(localStorageMock, 'observe'); localStorageUseSshStoreStub = jest.spyOn(localStorageMock, 'store'); + getVcsAccessTokenSpy = jest.spyOn(accountService, 'getVcsAccessToken'); + createVcsAccessTokenSpy = jest.spyOn(accountService, 'createVcsAccessToken'); + localStorageUseSshObserveStubSubject = new Subject(); localStorageUseSshObserveStub.mockReturnValue(localStorageUseSshObserveStubSubject); @@ -118,6 +126,15 @@ describe('CodeButtonComponent', () => { component.user = user; }); + // Mock the functions after the TestBed setup + beforeEach(() => { + getVcsAccessTokenSpy = jest.spyOn(accountService, 'getVcsAccessToken').mockReturnValue(of(new HttpResponse({ body: vcsToken }))); + + createVcsAccessTokenSpy = jest + .spyOn(accountService, 'createVcsAccessToken') + .mockReturnValue(throwError(() => new HttpErrorResponse({ status: 400, statusText: 'Bad Request' }))); + }); + afterEach(() => { // completely restore all fakes created through the sandbox jest.restoreAllMocks(); @@ -128,12 +145,44 @@ describe('CodeButtonComponent', () => { component.ngOnInit(); tick(); - expect(component.setupSshKeysUrl).toBe(info.sshKeysURL); + expect(component.setupSshKeysUrl).toBe(`${window.location.origin}/user-settings/sshSettings`); expect(component.sshTemplateUrl).toBe(info.sshCloneURLTemplate); expect(component.sshEnabled).toBe(!!info.sshCloneURLTemplate); expect(component.versionControlUrl).toBe(info.versionControlUrl); })); + it('should create new vcsAccessToken when it does not exist', fakeAsync(() => { + createVcsAccessTokenSpy = jest.spyOn(accountService, 'createVcsAccessToken').mockReturnValue(of(new HttpResponse({ body: vcsToken }))); + getVcsAccessTokenSpy = jest.spyOn(accountService, 'getVcsAccessToken').mockReturnValue(throwError(() => new HttpErrorResponse({ status: 404, statusText: 'Not found' }))); + stubServices(); + participation.id = 1; + component.participations = [participation]; + component.ngOnChanges(); + tick(); + component.ngOnInit(); + tick(); + + expect(component.useVersionControlAccessToken).toBeTrue(); + expect(component.user.vcsAccessToken).toEqual(vcsToken); + expect(getVcsAccessTokenSpy).toHaveBeenCalled(); + expect(createVcsAccessTokenSpy).toHaveBeenCalled(); + })); + + it('should not create new vcsAccessToken when it exists', fakeAsync(() => { + participation.id = 1; + component.participations = [participation]; + stubServices(); + component.ngOnChanges(); + tick(); + component.ngOnInit(); + tick(); + + expect(component.useVersionControlAccessToken).toBeTrue(); + expect(component.user.vcsAccessToken).toEqual(vcsToken); + expect(getVcsAccessTokenSpy).toHaveBeenCalled(); + expect(createVcsAccessTokenSpy).not.toHaveBeenCalled(); + })); + it('should get ssh url (same url for team and individual participation)', () => { participation.repositoryUri = 'https://gitlab.ase.in.tum.de/scm/ITCPLEASE1/itcplease1-exercise.git'; participation.team = {}; @@ -158,7 +207,8 @@ describe('CodeButtonComponent', () => { component.participations = [participation]; component.useSsh = true; component.isTeamParticipation = false; - component.versionControlAccessTokenRequired = true; + component.useVersionControlAccessToken = true; + component.useToken = true; component.ngOnInit(); component.ngOnChanges(); @@ -207,8 +257,9 @@ describe('CodeButtonComponent', () => { participation.repositoryUri = `https://${component.user.login}@gitlab.ase.in.tum.de/scm/ITCPLEASE1/itcplease1-exercise-team1.git`; component.participations = [participation]; component.useSsh = false; + component.useToken = true; component.isTeamParticipation = false; - component.versionControlAccessTokenRequired = true; + component.useVersionControlAccessToken = true; component.ngOnInit(); component.ngOnChanges(); @@ -236,8 +287,9 @@ describe('CodeButtonComponent', () => { participation.repositoryUri = `https://gitlab.ase.in.tum.de/scm/ITCPLEASE1/itcplease1-exercise-team1.git`; component.participations = [participation]; component.useSsh = false; + component.useToken = true; component.isTeamParticipation = false; - component.versionControlAccessTokenRequired = true; + component.useVersionControlAccessToken = true; component.ngOnInit(); component.ngOnChanges(); @@ -307,6 +359,12 @@ describe('CodeButtonComponent', () => { expect(localStorageUseSshStoreStub).toHaveBeenCalledWith('useSsh', false); expect(component.useSsh).toBeFalsy(); + fixture.debugElement.query(By.css('#useHTTPSWithTokenButton')).nativeElement.click(); + tick(); + expect(localStorageUseSshStoreStub).toHaveBeenCalledWith('useSsh', false); + expect(component.useSsh).toBeFalsy(); + expect(component.useToken).toBeTruthy(); + localStorageUseSshObserveStubSubject.next(true); tick(); expect(component.useSsh).toBeTruthy(); diff --git a/src/test/javascript/spec/helpers/mocks/service/mock-account.service.ts b/src/test/javascript/spec/helpers/mocks/service/mock-account.service.ts index 6ea86ae8b0c3..421a28217750 100644 --- a/src/test/javascript/spec/helpers/mocks/service/mock-account.service.ts +++ b/src/test/javascript/spec/helpers/mocks/service/mock-account.service.ts @@ -40,4 +40,6 @@ export class MockAccountService implements IAccountService { isAdmin = () => true; save = (account: any) => ({}) as any; addSshPublicKey = (sshPublicKey: string) => of(); + getVcsAccessToken = (participationId: number) => of(); + createVcsAccessToken = (participationId: number) => of(); } diff --git a/src/test/playwright/e2e/exercise/programming/ProgrammingExerciseParticipation.spec.ts b/src/test/playwright/e2e/exercise/programming/ProgrammingExerciseParticipation.spec.ts index 20169e667617..f0540dc21dc3 100644 --- a/src/test/playwright/e2e/exercise/programming/ProgrammingExerciseParticipation.spec.ts +++ b/src/test/playwright/e2e/exercise/programming/ProgrammingExerciseParticipation.spec.ts @@ -32,7 +32,12 @@ test.describe('Programming exercise participation', () => { }); const testCases = [ - { description: 'Makes a failing Java submission', programmingLanguage: ProgrammingLanguage.JAVA, submission: javaBuildErrorSubmission, commitMessage: 'Initial commit' }, + { + description: 'Makes a failing Java submission', + programmingLanguage: ProgrammingLanguage.JAVA, + submission: javaBuildErrorSubmission, + commitMessage: 'Initial commit', + }, { description: 'Makes a partially successful Java submission', programmingLanguage: ProgrammingLanguage.JAVA, @@ -235,6 +240,7 @@ async function makeGitExerciseSubmission( repoUrl = repoUrl.replace('localhost', 'artemis-app'); } repoUrl = repoUrl.replace(student.username!, `${student.username!}:${student.password!}`); + repoUrl = repoUrl.replace(`:**********`, ``); const urlParts = repoUrl.split('/'); const repoName = urlParts[urlParts.length - 1]; const exerciseRepo = await gitClient.cloneRepo(repoUrl, repoName);