Skip to content

General: Add more details to login email #10934

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 52 commits into from
Jun 5, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
52 commits
Select commit Hold shift + click to select a range
90c0081
Make sure to send email on passkey login
florian-glombik May 28, 2025
3a6695f
Make sure to send Mail on SAML2 login
florian-glombik May 28, 2025
91ba67d
Convey information of login type
florian-glombik May 28, 2025
fc5cc17
Move sending Mail to service
florian-glombik May 28, 2025
3c5d666
Use the CustomAuditEventRepository to send the login mail
florian-glombik May 28, 2025
7a162ad
Remove not required dependencies
florian-glombik May 28, 2025
dc0d06e
Revert "Remove not required dependencies"
florian-glombik May 30, 2025
874ea2e
Revert "Use the CustomAuditEventRepository to send the login mail"
florian-glombik May 30, 2025
62e61ca
Do not hardcode header names
florian-glombik May 30, 2025
7a75727
Try to retrieve client from userAgent
florian-glombik May 30, 2025
8610b9c
Merge branch 'develop' into feature/general/add-login-method-to-email
florian-glombik May 30, 2025
6a791a4
Add tests for detecting the client type
florian-glombik May 30, 2025
4a2bdf6
Support detecting android app
florian-glombik May 30, 2025
1135851
Add tests for most common browsers
florian-glombik May 30, 2025
95da7c5
Return actual browser name
florian-glombik May 30, 2025
d339f91
Fix browser name retrieval
florian-glombik May 30, 2025
9997576
Add test for brave
florian-glombik May 30, 2025
9911ca6
Adjust mail template
florian-glombik May 30, 2025
840bfb8
Localize authentication method
florian-glombik May 30, 2025
73cc099
Add enums and stricter types
florian-glombik May 30, 2025
a2f07a1
Make sure to set app aswell
florian-glombik May 30, 2025
d0f1cbc
Annotate client environment as nullable
florian-glombik May 30, 2025
37206ed
Make sure app and browser origin work together properly with translat…
florian-glombik May 30, 2025
bf80e84
Fix formatting of browserToOsConnection
florian-glombik May 30, 2025
8d8bfa5
Make implementation more robust
florian-glombik May 30, 2025
018f0b6
Reduce code duplication
florian-glombik May 30, 2025
827b823
Add timezone to email text
florian-glombik May 30, 2025
fa0e903
Reduce space for farewell
florian-glombik May 30, 2025
938e07f
Make sure locale is always defined
florian-glombik May 30, 2025
463f0bc
Add warning in case user has no language defined
florian-glombik May 30, 2025
14a82c0
Add javadoc
florian-glombik May 30, 2025
1617315
Add javadoc
florian-glombik May 30, 2025
152cd7b
Merge branch 'develop' into feature/general/add-login-method-to-email
florian-glombik May 30, 2025
8bc97cb
Improve javadoc
florian-glombik May 30, 2025
0c63c12
Improve Unit tests
florian-glombik May 30, 2025
398c09d
Add tests for the operating system retrival
florian-glombik May 30, 2025
7f51179
Unify test names
florian-glombik May 30, 2025
ec8bf68
Fix arch tests
florian-glombik May 30, 2025
ed19d9a
Adjust todo
florian-glombik May 31, 2025
1914862
Make sure sendLoginEmail is asnyc
florian-glombik May 31, 2025
baa3f75
Make coderabbit happy
florian-glombik May 31, 2025
970a88b
Do not hardcode production values in password reset
florian-glombik May 31, 2025
bec9b05
Merge branch 'develop' into feature/general/add-login-method-to-email
florian-glombik May 31, 2025
018caac
Make sure that password reset links are setup properly
florian-glombik May 31, 2025
218ae66
Log that a default value was used
florian-glombik May 31, 2025
dbef7dd
Adhrere to architecture rules
florian-glombik May 31, 2025
c097ded
Merge branch 'develop' into feature/general/add-login-method-to-email
florian-glombik Jun 1, 2025
494cac4
Address Tobias' review
florian-glombik Jun 1, 2025
38402bd
Address further review comments
florian-glombik Jun 1, 2025
b3c9f43
Fix typo
florian-glombik Jun 2, 2025
d1bd970
Merge branch 'develop' into feature/general/add-login-method-to-email
florian-glombik Jun 3, 2025
2b0c800
Merge branch 'develop' into feature/general/add-login-method-to-email
florian-glombik Jun 4, 2025
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 @@ -5,6 +5,8 @@
import org.springframework.security.saml2.provider.service.authentication.Saml2Authentication;
import org.springframework.security.web.webauthn.authentication.WebAuthnAuthentication;

import de.tum.cit.aet.artemis.core.domain.Language;

public enum AuthenticationMethod {

PASSKEY("passkey"), PASSWORD("password"), SAML2("saml2");
Expand Down Expand Up @@ -49,4 +51,18 @@ public static AuthenticationMethod fromMethod(String method) {
}
throw new IllegalArgumentException("Unknown authentication method: " + method);
}

/**
* Returns the email displayName of the authentication method based on the provided language.
*
* @param language the {@link Language} code
* @return the localized display name
*/
public String getEmailDisplayName(Language language) {
return switch (this) {
case PASSKEY -> language == Language.GERMAN ? "Passkey" : "passkey";
case PASSWORD -> language == Language.GERMAN ? "Passwort" : "password";
case SAML2 -> "SAML2";
};
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -205,7 +205,7 @@ public static JwtWithSource extractValidJwt(HttpServletRequest httpServletReques
"request_uri": "{}",
"headers": {}
}
""", source, httpServletRequest.getRemoteAddr(), httpServletRequest.getHeader("User-Agent"), httpServletRequest.getRequestURI(),
""", source, httpServletRequest.getRemoteAddr(), httpServletRequest.getHeader(HttpHeaders.USER_AGENT), httpServletRequest.getRequestURI(),
collectHeaders(httpServletRequest));
return null;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,22 +6,23 @@
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;

import org.springframework.context.ApplicationEventPublisher;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseCookie;
import org.springframework.http.converter.GenericHttpMessageConverter;
import org.springframework.http.converter.HttpMessageConverter;
import org.springframework.http.server.ServletServerHttpResponse;
import org.springframework.security.authentication.event.AuthenticationSuccessEvent;
import org.springframework.security.core.Authentication;
import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
import org.springframework.security.web.savedrequest.HttpSessionRequestCache;
import org.springframework.security.web.savedrequest.RequestCache;
import org.springframework.security.web.savedrequest.SavedRequest;
import org.springframework.util.Assert;

import de.tum.cit.aet.artemis.core.security.jwt.AuthenticationMethod;
import de.tum.cit.aet.artemis.core.security.jwt.JWTCookieService;
import de.tum.cit.aet.artemis.core.service.ArtemisSuccessfulLoginService;
import de.tum.cit.aet.artemis.core.util.HttpRequestUtils;

/**
* An {@link AuthenticationSuccessHandler}, that sets a JWT token in the response and writes a JSON response with the redirect
Expand All @@ -37,13 +38,13 @@ public final class ArtemisHttpMessageConverterAuthenticationSuccessHandler imple

private final JWTCookieService jwtCookieService;

private final ApplicationEventPublisher eventPublisher;
private final ArtemisSuccessfulLoginService artemisSuccessfulLoginService;

public ArtemisHttpMessageConverterAuthenticationSuccessHandler(HttpMessageConverter<Object> converter, JWTCookieService jwtCookieService,
ApplicationEventPublisher eventPublisher) {
ArtemisSuccessfulLoginService artemisSuccessfulLoginService) {
this.jwtCookieService = jwtCookieService;
this.converter = converter;
this.eventPublisher = eventPublisher;
this.artemisSuccessfulLoginService = artemisSuccessfulLoginService;
}

/**
Expand Down Expand Up @@ -76,7 +77,7 @@ public void onAuthenticationSuccess(HttpServletRequest request, HttpServletRespo
ResponseCookie responseCookie = jwtCookieService.buildLoginCookie(rememberMe);
response.addHeader(HttpHeaders.SET_COOKIE, responseCookie.toString());

eventPublisher.publishEvent(new AuthenticationSuccessEvent(authentication));
artemisSuccessfulLoginService.sendLoginEmail(authentication.getName(), AuthenticationMethod.PASSKEY, HttpRequestUtils.getClientEnvironment(request));

this.converter.write(new AuthenticationSuccess(redirectUrl), MediaType.APPLICATION_JSON, new ServletServerHttpResponse(response));
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.context.annotation.Profile;
import org.springframework.http.converter.HttpMessageConverter;
import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter;
Expand All @@ -30,6 +29,7 @@
import de.tum.cit.aet.artemis.core.repository.UserRepository;
import de.tum.cit.aet.artemis.core.security.jwt.JWTCookieService;
import de.tum.cit.aet.artemis.core.service.AndroidFingerprintService;
import de.tum.cit.aet.artemis.core.service.ArtemisSuccessfulLoginService;
import de.tum.cit.aet.artemis.core.util.AndroidApkKeyHashUtil;

/**
Expand Down Expand Up @@ -59,7 +59,7 @@ public class ArtemisPasskeyWebAuthnConfigurer {

private final MailSendingService mailSendingService;

private final ApplicationEventPublisher eventPublisher;
private final ArtemisSuccessfulLoginService artemisSuccessfulLoginService;

@Value("${" + Constants.PASSKEY_ENABLED_PROPERTY_NAME + ":false}")
private boolean passkeyEnabled;
Expand All @@ -83,7 +83,7 @@ public ArtemisPasskeyWebAuthnConfigurer(MappingJackson2HttpMessageConverter conv
PublicKeyCredentialUserEntityRepository publicKeyCredentialUserEntityRepository, UserCredentialRepository userCredentialRepository,
PublicKeyCredentialCreationOptionsRepository publicKeyCredentialCreationOptionsRepository,
PublicKeyCredentialRequestOptionsRepository publicKeyCredentialRequestOptionsRepository, AndroidFingerprintService androidFingerprintService,
MailSendingService mailSendingService, ApplicationEventPublisher eventPublisher) {
MailSendingService mailSendingService, ArtemisSuccessfulLoginService artemisSuccessfulLoginService) {
this.converter = converter;
this.jwtCookieService = jwtCookieService;
this.userRepository = userRepository;
Expand All @@ -93,7 +93,7 @@ public ArtemisPasskeyWebAuthnConfigurer(MappingJackson2HttpMessageConverter conv
this.publicKeyCredentialRequestOptionsRepository = publicKeyCredentialRequestOptionsRepository;
this.androidFingerprintService = androidFingerprintService;
this.mailSendingService = mailSendingService;
this.eventPublisher = eventPublisher;
this.artemisSuccessfulLoginService = artemisSuccessfulLoginService;
}

/**
Expand Down Expand Up @@ -157,7 +157,8 @@ public boolean configure(HttpSecurity http) throws Exception {
}

WebAuthnConfigurer<HttpSecurity> webAuthnConfigurer = new ArtemisWebAuthnConfigurer<>(converter, jwtCookieService, userRepository, publicKeyCredentialUserEntityRepository,
userCredentialRepository, publicKeyCredentialCreationOptionsRepository, publicKeyCredentialRequestOptionsRepository, mailSendingService, eventPublisher);
userCredentialRepository, publicKeyCredentialCreationOptionsRepository, publicKeyCredentialRequestOptionsRepository, mailSendingService,
artemisSuccessfulLoginService);

http.with(webAuthnConfigurer, configurer -> {
configurer.allowedOrigins(allowedOrigins).rpId(relyingPartyId).rpName(relyingPartyName);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
package de.tum.cit.aet.artemis.core.security.passkey;

import org.springframework.context.ApplicationEventPublisher;
import org.springframework.http.HttpStatus;
import org.springframework.http.converter.HttpMessageConverter;
import org.springframework.security.web.authentication.AuthenticationEntryPointFailureHandler;
Expand All @@ -10,6 +9,7 @@
import org.springframework.security.web.webauthn.authentication.WebAuthnAuthenticationFilter;

import de.tum.cit.aet.artemis.core.security.jwt.JWTCookieService;
import de.tum.cit.aet.artemis.core.service.ArtemisSuccessfulLoginService;

/**
* We want to set the custom {@link ArtemisHttpMessageConverterAuthenticationSuccessHandler} here to make sure the JWT token is set in the response
Expand All @@ -19,12 +19,12 @@
public class ArtemisWebAuthnAuthenticationFilter extends WebAuthnAuthenticationFilter {

public ArtemisWebAuthnAuthenticationFilter(HttpMessageConverter<Object> converter, JWTCookieService jwtCookieService,
PublicKeyCredentialRequestOptionsRepository publicKeyCredentialRequestOptionsRepository, ApplicationEventPublisher eventPublisher) {
PublicKeyCredentialRequestOptionsRepository publicKeyCredentialRequestOptionsRepository, ArtemisSuccessfulLoginService artemisSuccessfulLoginService) {
super();
setSecurityContextRepository(new HttpSessionSecurityContextRepository());
setRequestOptionsRepository(publicKeyCredentialRequestOptionsRepository);
setAuthenticationFailureHandler(new AuthenticationEntryPointFailureHandler(new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED)));
setAuthenticationSuccessHandler(new ArtemisHttpMessageConverterAuthenticationSuccessHandler(converter, jwtCookieService, eventPublisher));
setAuthenticationSuccessHandler(new ArtemisHttpMessageConverterAuthenticationSuccessHandler(converter, jwtCookieService, artemisSuccessfulLoginService));
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.NoSuchBeanDefinitionException;
import org.springframework.context.ApplicationContext;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.http.converter.HttpMessageConverter;
import org.springframework.security.authentication.ProviderManager;
import org.springframework.security.config.annotation.web.HttpSecurityBuilder;
Expand All @@ -28,6 +27,7 @@
import de.tum.cit.aet.artemis.communication.service.notifications.MailSendingService;
import de.tum.cit.aet.artemis.core.repository.UserRepository;
import de.tum.cit.aet.artemis.core.security.jwt.JWTCookieService;
import de.tum.cit.aet.artemis.core.service.ArtemisSuccessfulLoginService;

/**
* <p>
Expand Down Expand Up @@ -81,13 +81,13 @@ public class ArtemisWebAuthnConfigurer<H extends HttpSecurityBuilder<H>> extends

private final MailSendingService mailSendingService;

private final ApplicationEventPublisher eventPublisher;
private final ArtemisSuccessfulLoginService artemisSuccessfulLoginService;

public ArtemisWebAuthnConfigurer(HttpMessageConverter<Object> converter, JWTCookieService jwtCookieService, UserRepository userRepository,
PublicKeyCredentialUserEntityRepository publicKeyCredentialUserEntityRepository, UserCredentialRepository userCredentialRepository,
PublicKeyCredentialCreationOptionsRepository publicKeyCredentialCreationOptionsRepository,
PublicKeyCredentialRequestOptionsRepository publicKeyCredentialRequestOptionsRepository, MailSendingService mailSendingService,
ApplicationEventPublisher eventPublisher) {
ArtemisSuccessfulLoginService artemisSuccessfulLoginService) {
this.converter = converter;
this.jwtCookieService = jwtCookieService;
this.publicKeyCredentialUserEntityRepository = publicKeyCredentialUserEntityRepository;
Expand All @@ -96,7 +96,7 @@ public ArtemisWebAuthnConfigurer(HttpMessageConverter<Object> converter, JWTCook
this.publicKeyCredentialCreationOptionsRepository = publicKeyCredentialCreationOptionsRepository;
this.publicKeyCredentialRequestOptionsRepository = publicKeyCredentialRequestOptionsRepository;
this.mailSendingService = mailSendingService;
this.eventPublisher = eventPublisher;
this.artemisSuccessfulLoginService = artemisSuccessfulLoginService;
}

/**
Expand Down Expand Up @@ -150,7 +150,7 @@ public ArtemisWebAuthnConfigurer<H> allowedOrigins(Set<String> allowedOrigins) {
public void configure(H http) throws Exception {
WebAuthnRelyingPartyOperations rpOperations = webAuthnRelyingPartyOperations(publicKeyCredentialUserEntityRepository, userCredentialRepository);
WebAuthnAuthenticationFilter webAuthnAuthnFilter = new ArtemisWebAuthnAuthenticationFilter(converter, jwtCookieService, publicKeyCredentialRequestOptionsRepository,
eventPublisher);
artemisSuccessfulLoginService);

// we need to use custom repositories to ensure that multinode systems share challenges created in option requests
// the default implementation only works on single-node systems (at least on Spring Security version 6.4.4)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,22 @@
import java.time.format.DateTimeFormatter;
import java.util.HashMap;

import jakarta.annotation.Nullable;
import jakarta.annotation.PostConstruct;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Service;

import de.tum.cit.aet.artemis.communication.service.notifications.MailSendingService;
import de.tum.cit.aet.artemis.core.domain.Language;
import de.tum.cit.aet.artemis.core.domain.User;
import de.tum.cit.aet.artemis.core.exception.EntityNotFoundException;
import de.tum.cit.aet.artemis.core.repository.UserRepository;
import de.tum.cit.aet.artemis.core.security.jwt.AuthenticationMethod;
import de.tum.cit.aet.artemis.core.util.ClientEnvironment;

/**
* Listener for successful authentication events in the Artemis system.
Expand All @@ -27,21 +33,35 @@
@Service
public class ArtemisSuccessfulLoginService {

@Value("${artemis.user-management.password-reset.links.en:https://artemis.tum.de/account/reset/request}")
private static final Logger log = LoggerFactory.getLogger(ArtemisSuccessfulLoginService.class);

@Value("${artemis.user-management.password-reset.links.en}")
private String passwordResetLinkEnUrl;

@Value("${artemis.user-management.password-reset.links.de:https://artemis.tum.de/account/reset/request}")
@Value("${artemis.user-management.password-reset.links.de}")
private String passwordResetLinkDeUrl;

@Value("${server.url}")
private URL artemisServerUrl;

private static final Logger log = LoggerFactory.getLogger(ArtemisSuccessfulLoginService.class);

private final UserRepository userRepository;

private final MailSendingService mailSendingService;

@PostConstruct
public void ensurePasswordResetLinksAreInitializedProperly() {
String defaultPasswordResetLink = artemisServerUrl + "/account/reset/request";
String configurationPlaceholder = "<link>";
if (passwordResetLinkEnUrl == null || passwordResetLinkEnUrl.isEmpty() || passwordResetLinkEnUrl.equals(configurationPlaceholder)) {
log.info("No password reset link configured for English, using default link {}", defaultPasswordResetLink);
passwordResetLinkEnUrl = defaultPasswordResetLink;
}
if (passwordResetLinkDeUrl == null || passwordResetLinkDeUrl.isEmpty() || passwordResetLinkDeUrl.equals(configurationPlaceholder)) {
log.info("No password reset link configured for German, using default link {}", defaultPasswordResetLink);
passwordResetLinkDeUrl = defaultPasswordResetLink;
}
}

public ArtemisSuccessfulLoginService(UserRepository userRepository, MailSendingService mailSendingService) {
this.userRepository = userRepository;
this.mailSendingService = mailSendingService;
Expand All @@ -53,24 +73,31 @@ public ArtemisSuccessfulLoginService(UserRepository userRepository, MailSendingS
*
* @param username the username of the user who has successfully logged in
*/
public void sendLoginEmail(String username) {
public void sendLoginEmail(String username, AuthenticationMethod authenticationMethod, @Nullable ClientEnvironment clientEnvironment) {
try {
User recipient = userRepository.getUserByLoginElseThrow(username);
var contextVariables = new HashMap<String, Object>();
ZonedDateTime now = ZonedDateTime.now();
contextVariables.put("loginDate", now.format(DateTimeFormatter.ofPattern("dd.MM.yyyy")));
contextVariables.put("loginTime", now.format(DateTimeFormatter.ofPattern("HH:mm:ss")));

String localeKey = recipient.getLangKey();
if (localeKey == null) {
log.warn("User {} has no language set, using default language 'en'", username);
localeKey = "en";
}
Language language = Language.fromLanguageShortName(localeKey);

var contextVariables = new HashMap<String, Object>();
contextVariables.put("authenticationMethod", authenticationMethod.getEmailDisplayName(language));
ZonedDateTime now = ZonedDateTime.now();
contextVariables.put("loginDate", now.format(DateTimeFormatter.ofPattern("dd.MM.yyyy")));
contextVariables.put("loginTime", now.format(DateTimeFormatter.ofPattern("HH:mm:ss '('VV')'")));

String environmentInfo = clientEnvironment != null ? clientEnvironment.getEnvironmentInfo(language) : ClientEnvironment.getUnknownEnvironmentDisplayName(language);
contextVariables.put("requestOrigin", environmentInfo);

if (recipient.isInternal()) {
contextVariables.put("resetLink", artemisServerUrl.toString() + "/account/password");
}
else {
if (localeKey.equals("de")) {
if (language == Language.GERMAN) {
contextVariables.put("resetLink", passwordResetLinkDeUrl);
}
else {
Expand Down
Loading
Loading