Skip to content
Open
Show file tree
Hide file tree
Changes from 33 commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
7272e53
Merge branch 'develop' of https://github.com/ls1intum/Artemis into de…
Lyynnnx May 27, 2026
473500f
Merge branch 'develop' of https://github.com/ls1intum/Artemis into de…
Lyynnnx May 28, 2026
d728326
Merge branch 'develop' of https://github.com/ls1intum/Artemis into de…
Lyynnnx May 29, 2026
7ce6207
Merge branch 'develop' of https://github.com/ls1intum/Artemis into de…
Lyynnnx May 31, 2026
307ad62
Merge branch 'develop' of https://github.com/ls1intum/Artemis into de…
Lyynnnx Jun 2, 2026
8dd5f0b
Merge branch 'develop' of https://github.com/ls1intum/Artemis into de…
Lyynnnx Jun 4, 2026
de6a56a
Merge branch 'develop' of https://github.com/ls1intum/Artemis into de…
Lyynnnx Jun 7, 2026
134b8ee
Merge branch 'develop' of https://github.com/ls1intum/Artemis into de…
Lyynnnx Jun 9, 2026
9320256
Merge branch 'develop' of https://github.com/ls1intum/Artemis into de…
Lyynnnx Jun 10, 2026
28bddce
Merge branch 'develop' of https://github.com/ls1intum/Artemis into de…
Lyynnnx Jun 10, 2026
5a555f8
Merge branch 'develop' of https://github.com/ls1intum/Artemis into de…
Lyynnnx Jun 20, 2026
802bc45
Merge branch 'develop' of https://github.com/ls1intum/Artemis into de…
Lyynnnx Jun 24, 2026
194cb93
Merge branch 'develop' of https://github.com/ls1intum/Artemis into de…
Lyynnnx Jun 26, 2026
83c4f58
Merge branch 'develop' of https://github.com/ls1intum/Artemis into de…
Lyynnnx Jun 28, 2026
83dfa18
Merge branch 'develop' of https://github.com/ls1intum/Artemis into de…
Lyynnnx Jun 30, 2026
5dadce3
Merge branch 'develop' of https://github.com/ls1intum/Artemis into de…
Lyynnnx Jul 3, 2026
efbc22a
Merge branch 'develop' of https://github.com/ls1intum/Artemis into de…
Lyynnnx Jul 4, 2026
4454cd6
Merge branch 'develop' of https://github.com/ls1intum/Artemis into de…
Lyynnnx Jul 7, 2026
c997c7c
Merge branch 'develop' of https://github.com/ls1intum/Artemis into de…
Lyynnnx Jul 8, 2026
56fe6a9
Merge branch 'develop' of https://github.com/ls1intum/Artemis into de…
Lyynnnx Jul 31, 2026
55f8847
Merge branch 'develop' of https://github.com/ls1intum/Artemis into de…
Lyynnnx Aug 3, 2026
abab092
Merge branch 'develop' of https://github.com/ls1intum/Artemis into de…
Lyynnnx Aug 4, 2026
5c8862c
Merge branch 'develop' of https://github.com/ls1intum/Artemis into de…
Lyynnnx Aug 4, 2026
7ef771d
Merge branch 'develop' of https://github.com/ls1intum/Artemis into de…
Lyynnnx Aug 9, 2026
76a4478
Merge branch 'develop' of https://github.com/ls1intum/Artemis into de…
Lyynnnx Aug 10, 2026
bae7456
Merge branch 'develop' of https://github.com/ls1intum/Artemis into de…
Lyynnnx Aug 11, 2026
84f14dc
pull develop
Lyynnnx Aug 12, 2026
45c5c17
Merge branch 'develop' of https://github.com/ls1intum/Artemis into de…
Lyynnnx Aug 15, 2026
2b28b1b
Merge branch 'develop' of https://github.com/ls1intum/Artemis into de…
Lyynnnx Aug 20, 2026
c395eff
Merge branch 'develop' of https://github.com/ls1intum/Artemis into de…
Lyynnnx Aug 21, 2026
cf86000
initial logic for email notification
Lyynnnx Aug 21, 2026
3edf114
Merge remote-tracking branch 'origin/develop' into chore/send-email-m…
Lyynnnx Aug 23, 2026
7793314
replace hazelcast instance with disrtibutedDataProvider
Lyynnnx Aug 23, 2026
4986086
improve the clarity of notification text
Lyynnnx Aug 23, 2026
98d57b5
refine token message
Lyynnnx Aug 23, 2026
3b3048b
Merge branch 'develop' into chore/send-email-message-if-https-used
Lyynnnx Aug 23, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.MessageDigest;
import java.time.Duration;
import java.time.ZonedDateTime;
import java.util.Base64;
import java.util.List;
Expand Down Expand Up @@ -54,6 +55,8 @@
import de.tum.cit.aet.artemis.core.security.RateLimitType;
import de.tum.cit.aet.artemis.core.security.SecurityUtils;
import de.tum.cit.aet.artemis.core.service.AuthorizationCheckService;
import de.tum.cit.aet.artemis.core.service.distributed.api.DistributedDataProvider;
import de.tum.cit.aet.artemis.core.service.distributed.api.map.DistributedMap;
import de.tum.cit.aet.artemis.core.util.TimeLogUtil;
import de.tum.cit.aet.artemis.exercise.domain.Exercise;
import de.tum.cit.aet.artemis.exercise.domain.participation.Participation;
Expand All @@ -63,6 +66,8 @@
import de.tum.cit.aet.artemis.localvc.exception.LocalVCForbiddenException;
import de.tum.cit.aet.artemis.localvc.exception.LocalVCInternalException;
import de.tum.cit.aet.artemis.localvc.service.ssh.SshConstants;
import de.tum.cit.aet.artemis.notification.dto.MailRecipientDTO;
import de.tum.cit.aet.artemis.notification.service.notifications.MailSendingService;
import de.tum.cit.aet.artemis.programming.domain.AuthenticationMechanism;
import de.tum.cit.aet.artemis.programming.domain.Commit;
import de.tum.cit.aet.artemis.programming.domain.ProgrammingExercise;
Expand Down Expand Up @@ -132,6 +137,10 @@ public class LocalVCServletService {

private final ExerciseVersionService exerciseVersionService;

private final MailSendingService mailSendingService;

private final DistributedDataProvider distributedDataProvider;

@Value("${artemis.version-control.url}")
private URI localVCBaseUri;

Expand All @@ -146,13 +155,15 @@ public class LocalVCServletService {

public static final String BUILD_USER_NAME = "buildjob_user";

public static final String HTTPS_CLONE_EMAIL_CACHE = "httpsCloneWarningEmailCache";

public LocalVCServletService(AuthenticationManager authenticationManager, UserRepository userRepository, ProgrammingExerciseRepository programmingExerciseRepository,
RepositoryAccessService repositoryAccessService, ProgrammingExerciseParticipationService programmingExerciseParticipationService,
AuxiliaryRepositoryService auxiliaryRepositoryService, ContinuousIntegrationTriggerService ciTriggerService, ProgrammingSubmissionService programmingSubmissionService,
ProgrammingSubmissionMessagingService programmingSubmissionMessagingService, ProgrammingExerciseTestCaseChangedService programmingExerciseTestCaseChangedService,
ParticipationVCSAccessTokenRepository participationVCSAccessTokenRepository, RepositoryVCSAccessTokenRepository repositoryVCSAccessTokenRepository,
Optional<VcsAccessLogService> vcsAccessLogService, AuthorizationCheckService authorizationCheckService, RateLimitService rateLimitService,
ExerciseVersionService exerciseVersionService) {
ExerciseVersionService exerciseVersionService, MailSendingService mailSendingService, DistributedDataProvider distributedDataProvider) {
this.authenticationManager = authenticationManager;
this.userRepository = userRepository;
this.programmingExerciseRepository = programmingExerciseRepository;
Expand All @@ -169,6 +180,8 @@ public LocalVCServletService(AuthenticationManager authenticationManager, UserRe
this.authorizationCheckService = authorizationCheckService;
this.rateLimitService = rateLimitService;
this.exerciseVersionService = exerciseVersionService;
this.mailSendingService = mailSendingService;
this.distributedDataProvider = distributedDataProvider;
}

/**
Expand Down Expand Up @@ -1233,8 +1246,20 @@ public void updateAndStoreVCSAccessLogForCloneAndPullHTTPS(HttpServletRequest re
if (userName.equals(BUILD_USER_NAME)) {
return;
}

Optional<User> userOptional = userRepository.findOneByLogin(userName);
if (userOptional.isEmpty()) {
return;
}
User user = userOptional.get();

LocalVCRepositoryUri localVCRepositoryUri = parseRepositoryUri(request);
RepositoryActionType repositoryActionType = getRepositoryActionReadType(clientOffered);
AuthenticationMechanism mechanism = resolveHTTPSAuthenticationMechanism(authorizationHeader, user, localVCRepositoryUri);
// if user does a clone operation using a PASSWORD authentication, then system should notify user about better option
if (mechanism == AuthenticationMechanism.PASSWORD && repositoryActionType == RepositoryActionType.CLONE) {
checkAndSendHttpsCloneEmail(user);
}

vcsAccessLogService.ifPresent(service -> service.updateRepositoryActionType(localVCRepositoryUri, repositoryActionType));
}
Expand All @@ -1243,6 +1268,26 @@ public void updateAndStoreVCSAccessLogForCloneAndPullHTTPS(HttpServletRequest re
}
}

/**
* Sends an email tip about using Token/SSH authentication instead of HTTPS password,
* limited to at most once per 24 hours per user using distributed cache.
*
* @param user The user performing the clone operation.
*/
private void checkAndSendHttpsCloneEmail(User user) {
if (user.getEmail() == null || user.getEmail().isBlank()) {
return;
}
DistributedMap<Long, Boolean> cache = distributedDataProvider.getExpiringMap(HTTPS_CLONE_EMAIL_CACHE, Duration.ofHours(24));
boolean isFirstTimeIn24Hours = cache.putIfAbsent(user.getId(), Boolean.TRUE, Duration.ofHours(24)) == null;
// If cache was empty then send an email
if (isFirstTimeIn24Hours) {
MailRecipientDTO mailRecipient = new MailRecipientDTO(user.getEmail(), user.getLangKey(), user.getLogin(), user.getFirstName(), user.getLastName(), null, null);

mailSendingService.buildAndSendAsync(mailRecipient, "email.httpsCloneTip.title", "mail/httpsCloneTipEmail", Map.of());
}
}

/**
* Updates the VCS access log for clone and pull actions performed over SSH.
* <p>
Expand Down
6 changes: 6 additions & 0 deletions src/main/resources/i18n/messages_de.properties
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,12 @@ email.notification.title.exercise.information.bonus=Anzahl an Bonus Punkten: {0}
email.notification.title.exercise.information.possible=Anzahl maximal erreichbarer Punkte: {0}
email.notification.aux.information.exercise.score=Dein erreichtes Ergebnis: {0}% korrekt

# HTTPS Clone Tip
email.httpsCloneTip.title=Artemis-Tipp: Verwende Token- oder SSH-Authentifizierung
email.httpsCloneTip.greeting=Hallo {0},
email.httpsCloneTip.message=Du verwendest derzeit die HTTPS-Passwort-Authentifizierungsmethode. Wechsle zu Token oder SSH, um deine Arbeitszeit zu verbessern.
email.httpsCloneTip.action=Einstellungen in Artemis öffnen
email.httpsCloneTip.farewell=Viele Grüße,\nDas Artemis-Team

# Auxiliary
email.notification.aux.notification.text.header.change.message=Änderungsnachricht:
Expand Down
7 changes: 7 additions & 0 deletions src/main/resources/i18n/messages_en.properties
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,13 @@ email.notification.title.exercise.information.bonus=Number of Bonus Points : {0}
email.notification.title.exercise.information.possible=Number of possible max Points : {0}
email.notification.aux.information.exercise.score=Your achieved Score : {0}% correct

# HTTPS Clone Tip
email.httpsCloneTip.title=Artemis Tip: Use Token or SSH authentication
email.httpsCloneTip.greeting=Dear {0},
email.httpsCloneTip.message=You are currently using the HTTPS password authentication method. Change your authentication option to Token or SSH to improve your development time.
email.httpsCloneTip.action=Open Settings in Artemis
email.httpsCloneTip.farewell=Regards,\nThe Artemis Team.

# Auxiliary
email.notification.aux.notification.text.header.change.message=Change message :
email.notification.aux.footer=You can (un)subscribe to similar emails in your
Expand Down
22 changes: 22 additions & 0 deletions src/main/resources/templates/mail/httpsCloneTipEmail.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<title th:text="#{email.httpsCloneTip.title}">Artemis Tip</title>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<link rel="shortcut icon" th:href="@{|${baseUrl}/favicon.ico|}" />
</head>
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333333;">
<p th:text="#{email.httpsCloneTip.greeting(${user.firstName != null ? user.firstName : user.login})}">Dear User,</p>

<p th:text="#{email.httpsCloneTip.message}">
You are currently using the HTTPS password authentication method for Git operations.
We recommend switching to the Token or SSH authentication option to improve your working time and security.
</p>

<p>
<a th:href="@{|${baseUrl}/user-settings/ssh|}" th:text="#{email.httpsCloneTip.action}">Configure SSH / Tokens in Artemis</a>
</p>

<p style="white-space: pre-line;" th:text="#{email.httpsCloneTip.farewell}">Regards,<br/>The Artemis Team</p>
</body>
</html>
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,14 @@
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;

import java.net.URI;
import java.time.Duration;
import java.util.Map;
import java.util.Optional;

import jakarta.servlet.http.HttpServletRequest;
Expand All @@ -26,9 +31,15 @@

import de.tum.cit.aet.artemis.account.domain.User;
import de.tum.cit.aet.artemis.account.test_repository.UserTestRepository;
import de.tum.cit.aet.artemis.admin.service.RateLimitService;
import de.tum.cit.aet.artemis.core.service.AuthorizationCheckService;
import de.tum.cit.aet.artemis.core.service.distributed.api.DistributedDataProvider;
import de.tum.cit.aet.artemis.core.service.distributed.api.map.DistributedMap;
import de.tum.cit.aet.artemis.course.domain.Course;
import de.tum.cit.aet.artemis.exercise.service.ExerciseVersionService;
import de.tum.cit.aet.artemis.localci.service.ci.ContinuousIntegrationTriggerService;
import de.tum.cit.aet.artemis.notification.dto.MailRecipientDTO;
import de.tum.cit.aet.artemis.notification.service.notifications.MailSendingService;
import de.tum.cit.aet.artemis.programming.domain.AuthenticationMechanism;
import de.tum.cit.aet.artemis.programming.domain.ProgrammingExercise;
import de.tum.cit.aet.artemis.programming.domain.ProgrammingExerciseStudentParticipation;
Expand All @@ -37,9 +48,9 @@
import de.tum.cit.aet.artemis.programming.repository.RepositoryVCSAccessTokenRepository;
import de.tum.cit.aet.artemis.programming.service.AuxiliaryRepositoryService;
import de.tum.cit.aet.artemis.programming.service.ProgrammingExerciseParticipationService;
import de.tum.cit.aet.artemis.programming.service.ProgrammingMessagingService;
import de.tum.cit.aet.artemis.programming.service.ProgrammingExerciseTestCaseChangedService;
import de.tum.cit.aet.artemis.programming.service.ProgrammingSubmissionMessagingService;
import de.tum.cit.aet.artemis.programming.service.ProgrammingSubmissionService;
import de.tum.cit.aet.artemis.programming.service.ProgrammingTriggerService;
import de.tum.cit.aet.artemis.programming.service.RepositoryAccessService;
import de.tum.cit.aet.artemis.programming.test_repository.ProgrammingExerciseTestRepository;
import de.tum.cit.aet.artemis.programming.web.repository.RepositoryActionType;
Expand Down Expand Up @@ -75,10 +86,10 @@ class LocalVCServletServiceTest {
private ProgrammingSubmissionService programmingSubmissionService;

@Mock
private ProgrammingMessagingService programmingMessagingService;
private ProgrammingSubmissionMessagingService programmingSubmissionMessagingService;

@Mock
private ProgrammingTriggerService programmingTriggerService;
private ProgrammingExerciseTestCaseChangedService programmingExerciseTestCaseChangedService;

@Mock
private ParticipationVCSAccessTokenRepository participationVCSAccessTokenRepository;
Expand All @@ -89,6 +100,21 @@ class LocalVCServletServiceTest {
@Mock
private VcsAccessLogService vcsAccessLogService;

@Mock
private RateLimitService rateLimitService;

@Mock
private ExerciseVersionService exerciseVersionService;

@Mock
private MailSendingService mailSendingService;

@Mock
private DistributedDataProvider distributedDataProvider;

@Mock
private DistributedMap<Long, Boolean> httpsCloneEmailCache;

@InjectMocks
private LocalVCServletService localVCServletService;

Expand All @@ -105,6 +131,10 @@ void setUp() throws Exception {
testUser = new User();
testUser.setId(1L);
testUser.setLogin("testuser");
testUser.setEmail("testuser@example.com");
testUser.setLangKey("en");
testUser.setFirstName("Test");
testUser.setLastName("User");

// Create a course with required properties
Course testCourse = new Course();
Expand All @@ -123,12 +153,15 @@ void setUp() throws Exception {
testRepositoryUri = mock(LocalVCRepositoryUri.class);
// Use lenient() to avoid unnecessary stubbing errors for tests that don't use this mock
lenient().when(testRepositoryUri.getRelativeRepositoryPath()).thenReturn(java.nio.file.Path.of("test/repo"));
lenient().when(testRepositoryUri.toString()).thenReturn("http://localhost/git/TEST/EXERCISE-template.git");
lenient().when(testRepositoryUri.toString()).thenReturn("http://localhost/git/TEST/test-template.git");

lenient().when(distributedDataProvider.<Long, Boolean>getExpiringMap(anyString(), any())).thenReturn(httpsCloneEmailCache);

// Setup the VcsAccessLogService as an Optional containing the mock
ReflectionTestUtils.setField(localVCServletService, "vcsAccessLogService", Optional.of(vcsAccessLogService));

ReflectionTestUtils.setField(localVCServletService, "localVCBasePath", java.nio.file.Path.of("/tmp/test-repos"));
ReflectionTestUtils.setField(localVCServletService, "localVCBaseUri", URI.create("http://localhost"));
}

@Test
Expand Down Expand Up @@ -250,7 +283,7 @@ void testResolveAuthenticationMechanismFromSessionOrRequest_withRepositoryToken(

RepositoryVCSAccessToken repositoryToken = new RepositoryVCSAccessToken();
repositoryToken.setVcsAccessToken(token);
when(repositoryVCSAccessTokenRepository.findByUserIdAndRepositoryUri(testUser.getId(), "http://localhost/git/TEST/EXERCISE-template.git"))
when(repositoryVCSAccessTokenRepository.findByUserIdAndRepositoryUri(testUser.getId(), "http://localhost/git/TEST/test-template.git"))
.thenReturn(Optional.of(repositoryToken));

AuthenticationContext.Request context = new AuthenticationContext.Request(request);
Expand Down Expand Up @@ -280,4 +313,76 @@ void testRepositoryActionTypeForFailedOperations() {
assertThat(RepositoryActionType.PUSH_FAIL).isNotNull();
assertThat(RepositoryActionType.CLONE_FAIL).isNotNull();
}

@Test
void testUpdateAndStoreVCSAccessLogForCloneAndPullHTTPS_sendsEmailOnPasswordCloneWhenNotInCache() {
HttpServletRequest request = mock(HttpServletRequest.class);
when(request.getMethod()).thenReturn("POST");
when(request.getRequestURI()).thenReturn("/git/TEST/test-testuser.git/git-upload-pack");

String password = "plain-password";
String authHeader = "Basic " + java.util.Base64.getEncoder().encodeToString(("testuser:" + password).getBytes());

when(userRepository.findOneByLogin("testuser")).thenReturn(Optional.of(testUser));
// putIfAbsent returns null on first insert
when(httpsCloneEmailCache.putIfAbsent(eq(testUser.getId()), eq(Boolean.TRUE), any(Duration.class))).thenReturn(null);

// clientOffered == 0 means CLONE operation
localVCServletService.updateAndStoreVCSAccessLogForCloneAndPullHTTPS(request, authHeader, 0);

verify(mailSendingService).buildAndSendAsync(any(MailRecipientDTO.class), eq("email.httpsCloneTip.title"), eq("mail/httpsCloneTipEmail"), eq(Map.of()));
}

@Test
void testUpdateAndStoreVCSAccessLogForCloneAndPullHTTPS_doesNotSendEmailWhenAlreadyInCache() {
HttpServletRequest request = mock(HttpServletRequest.class);
when(request.getMethod()).thenReturn("POST");
when(request.getRequestURI()).thenReturn("/git/TEST/test-testuser.git/git-upload-pack");

String password = "plain-password";
String authHeader = "Basic " + java.util.Base64.getEncoder().encodeToString(("testuser:" + password).getBytes());

when(userRepository.findOneByLogin("testuser")).thenReturn(Optional.of(testUser));
// putIfAbsent returns existing entry (Boolean.TRUE) when already present
when(httpsCloneEmailCache.putIfAbsent(eq(testUser.getId()), eq(Boolean.TRUE), any(Duration.class))).thenReturn(Boolean.TRUE);

localVCServletService.updateAndStoreVCSAccessLogForCloneAndPullHTTPS(request, authHeader, 0);

verify(mailSendingService, never()).buildAndSendAsync(any(), any(), any(), any());
}

@Test
void testUpdateAndStoreVCSAccessLogForCloneAndPullHTTPS_doesNotSendEmailWhenUsingToken() {
HttpServletRequest request = mock(HttpServletRequest.class);
when(request.getMethod()).thenReturn("POST");
when(request.getRequestURI()).thenReturn("/git/TEST/test-testuser.git/git-upload-pack");

String token = "vcpat-" + "a".repeat(44);
String authHeader = "Basic " + java.util.Base64.getEncoder().encodeToString(("testuser:" + token).getBytes());

when(userRepository.findOneByLogin("testuser")).thenReturn(Optional.of(testUser));

localVCServletService.updateAndStoreVCSAccessLogForCloneAndPullHTTPS(request, authHeader, 0);

verifyNoInteractions(mailSendingService);
verifyNoInteractions(httpsCloneEmailCache);
}

@Test
void testUpdateAndStoreVCSAccessLogForCloneAndPullHTTPS_doesNotSendEmailOnPullOperation() {
HttpServletRequest request = mock(HttpServletRequest.class);
when(request.getMethod()).thenReturn("POST");
when(request.getRequestURI()).thenReturn("/git/TEST/test-testuser.git/git-upload-pack");

String password = "plain-password";
String authHeader = "Basic " + java.util.Base64.getEncoder().encodeToString(("testuser:" + password).getBytes());

when(userRepository.findOneByLogin("testuser")).thenReturn(Optional.of(testUser));

// clientOffered > 0 means a PULL operation instead of clone
localVCServletService.updateAndStoreVCSAccessLogForCloneAndPullHTTPS(request, authHeader, 1);

verifyNoInteractions(mailSendingService);
verifyNoInteractions(httpsCloneEmailCache);
}
}
Loading