Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
0d1837e
Development: Stop extending a passkey session once its passkey is gone
krusche Aug 3, 2026
be449d2
Development: Keep the passkey claims when a token is rotated
krusche Aug 3, 2026
352bc69
Development: Bound session extension to seven-day windows with a chec…
krusche Aug 3, 2026
eda8f34
Development: Stop extending pre-claim passkey sessions once passkeys …
krusche Aug 3, 2026
cf65bb2
Development: Carry the remember-me flag into the token and timestamp …
krusche Aug 3, 2026
b0d2164
Merge remote-tracking branch 'origin/develop' into chore/verify-passk…
krusche Aug 4, 2026
ce6f60d
Development: Mock the test repository in the renewal service test
krusche Aug 4, 2026
74278e0
Merge remote-tracking branch 'origin/develop' into HEAD
krusche Aug 10, 2026
3d22107
Merge remote-tracking branch 'origin/develop' into HEAD
krusche Aug 11, 2026
eb494f2
Merge remote-tracking branch 'origin/develop' into pr13406
krusche Aug 12, 2026
2a71e13
General: Bound a remember-me session by lifetime, not only by count
krusche Aug 12, 2026
12a50c5
General: Inject the session settings and reject an unusable lifetime
krusche Aug 12, 2026
c04b616
Merge develop into chore/verify-passkey-still-exists-before-extending…
krusche Aug 12, 2026
b54ce37
Merge remote-tracking branch 'origin/develop' into chore/verify-passk…
krusche Aug 18, 2026
32b2dc0
Merge remote-tracking branch 'origin/develop' into chore/verify-passk…
krusche Aug 19, 2026
d24a4a7
Merge branch 'develop' into chore/verify-passkey-still-exists-before-…
krusche Aug 23, 2026
d34de9c
Development: Clip the initial login token to the session ceiling
krusche Aug 23, 2026
2c12313
Merge branch 'develop' into chore/verify-passkey-still-exists-before-…
krusche Aug 24, 2026
cd2f1a2
Merge branch 'develop' into chore/verify-passkey-still-exists-before-…
krusche Aug 25, 2026
ecc9f43
Development: Unblock the bean-instantiation check and register the se…
krusche Aug 25, 2026
34f30a8
Development: Parse the token once per request and align the session w…
krusche Aug 25, 2026
22b72bf
Development: Bound a remember-me session by the ceiling alone, not by…
krusche Aug 25, 2026
2b4de15
Development: Keep credentials_changed_date server-internal and run th…
krusche Aug 25, 2026
baca064
Merge remote-tracking branch 'origin/develop' into chore/verify-passk…
krusche Aug 25, 2026
7a12230
Development: Give a bare Awaitility await() the 30 seconds the suite …
krusche Aug 25, 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
2 changes: 1 addition & 1 deletion .github/workflows/ci-bean-instantiations.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ jobs:
MAX_STARTUP_DEPENDENCY_CHAIN_LENGTH: 10
MAX_DEFERRED_CHAIN_LENGTH: 16
MIN_INSTANTIATED_BEANS: 20
MAX_INSTANTIATED_BEANS: 142
MAX_INSTANTIATED_BEANS: 143
MIN_DEFERRED_CHAIN_LENGTH: 1

steps:
Expand Down
22 changes: 22 additions & 0 deletions src/main/java/de/tum/cit/aet/artemis/account/domain/User.java
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,19 @@ public class User extends AbstractAuditingEntity implements Participant {
@Column(name = "vcs_access_token_expiry_date")
private ZonedDateTime vcsAccessTokenExpiryDate = null;

/**
* When the account's credentials last changed - a completed password reset, a password change, or a deactivation.
* A session issued before this point is not extended any further, so those events end long-lived sessions within one
* rotation interval instead of leaving them to run to their full lifetime.
* <p>
* Server-internal, like the credential fields above: {@code User} itself is serialised by the course membership
* endpoints, so without {@code @JsonIgnore} this would tell every instructor and tutor when each of their course
* members last changed their password.
*/
@JsonIgnore
@Column(name = "credentials_changed_date")
private ZonedDateTime credentialsChangedDate = null;

@OneToMany(mappedBy = "user", fetch = FetchType.LAZY, cascade = CascadeType.REMOVE)
@JsonIgnore
private Set<UserCourseRole> courseRoles = new HashSet<>();
Expand Down Expand Up @@ -622,6 +635,15 @@ public String getVcsAccessToken() {
return vcsAccessToken;
}

@Nullable
public ZonedDateTime getCredentialsChangedDate() {
return credentialsChangedDate;
}

public void setCredentialsChangedDate(@Nullable ZonedDateTime credentialsChangedDate) {
this.credentialsChangedDate = credentialsChangedDate;
}

public void setVcsAccessToken(@Nullable String vcsAccessToken) {
this.vcsAccessToken = vcsAccessToken;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -109,14 +109,16 @@ public boolean supports(Class<?> authentication) {
}

/**
* Creates authentication details containing the passkey super admin approval status.
* Creates authentication details containing the passkey super admin approval status and the credential id.
*
* @param credential the passkey credential to check for super admin approval
* @return a map containing the authentication details with the passkey super admin approval status
*/
private Map<String, Object> createAuthenticationDetailsWithPasskeyApprovalStatus(PasskeyCredential credential) {
Map<String, Object> details = new HashMap<>();
details.put(TokenProvider.IS_PASSKEY_SUPER_ADMIN_APPROVED, credential.isSuperAdminApproved());
// Recorded so that a silent rotation can verify this passkey still exists before extending the session.
details.put(TokenProvider.PASSKEY_CREDENTIAL_ID, credential.getCredentialId());
return details;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import static de.tum.cit.aet.artemis.core.security.Role.STUDENT;

import java.time.Instant;
import java.time.ZonedDateTime;
import java.util.HashSet;
import java.util.Optional;
import java.util.Set;
Expand Down Expand Up @@ -267,8 +268,9 @@ public User updateUser(@NonNull User user, ManagedUserVM updatedUserDTO) {
if (updatedUserDTO.getImageUrl() != null) {
user.setImageUrl(updatedUserDTO.getImageUrl());
}
// Captured before the flag is overwritten: the admin edit form reaches the same transition as deactivateUser, and
// it has to revoke the same credentials, otherwise an account the admin sees as deactivated keeps working over git.
// Captured before the flag is overwritten: the admin edit form reaches the same two transitions as deactivateUser
// and a password reset do. A session established earlier has to stop being extended for both, and the credentials
// have to be revoked as well, otherwise an account the admin sees as deactivated keeps working over git.
boolean isBeingDeactivated = Boolean.TRUE.equals(user.getActivated()) && !updatedUserDTO.isActivated();
user.setActivated(updatedUserDTO.isActivated());
user.setTestUser(updatedUserDTO.isTestUser());
Expand All @@ -277,25 +279,38 @@ public User updateUser(@NonNull User user, ManagedUserVM updatedUserDTO) {
// if user was external and becomes internal - it's important to make sure that user still has a password
boolean wasInternal = user.isInternal();
user.setInternal(updatedUserDTO.isInternal());
boolean revokeCredentialsAfterPasswordChange = user.isInternal() && updatedUserDTO.getPassword() != null && updatedUserDTO.isRevokeCredentials();

// Set where the password is actually written rather than derived from the request, because only some requests that
// carry a password write it: an update that leaves the account external ignores it, and the changed date has to
// follow what happened to the credential, not what was asked for.
boolean isPasswordBeingChanged = false;
if (user.isInternal()) {
if (updatedUserDTO.getPassword() != null) {
user.setPassword(passwordService.hashPassword(updatedUserDTO.getPassword()));
isPasswordBeingChanged = true;
}
else if (!wasInternal || user.getPassword() == null) {
// If user becomes internal user and got no password, generate the random password
String newPassword = RandomUtil.generatePassword();
user.setPassword(passwordService.hashPassword(newPassword));
// Deliberately not treated as a password change: the account had no usable password before, so there is no
// earlier password-based session for the changed date to end.
}
}
// Bumping the changed date always stops an earlier session from being extended; revoking the other credentials on
// top of that stays opt-in, because the admin form asks for it separately.
boolean revokeCredentialsAfterPasswordChange = isPasswordBeingChanged && updatedUserDTO.isRevokeCredentials();
if (isBeingDeactivated || isPasswordBeingChanged) {
user.setCredentialsChangedDate(ZonedDateTime.now());
}
user.setOrganizations(updatedUserDTO.getOrganizations());
setUserAuthorities(updatedUserDTO, user);

log.debug("Changed Information for User: {}", user);

User savedUser = saveUser(user);
boolean passwordChangedByAdministrator = user.isInternal() && updatedUserDTO.getPassword() != null;
// Same condition as the changed date above, so the notice cannot claim a change the account did not receive.
boolean passwordChangedByAdministrator = isPasswordBeingChanged;
boolean credentialsRevoked = isBeingDeactivated || revokeCredentialsAfterPasswordChange;
if (credentialsRevoked) {
String reason = isBeingDeactivated ? "user deactivated by an administrator" : "password changed by an administrator";
Expand Down Expand Up @@ -351,6 +366,8 @@ public void activateUser(User user) {
*/
public void deactivateUser(User user) {
user.setActivated(false);
// Stops sessions established before the deactivation from being extended any further.
user.setCredentialsChangedDate(ZonedDateTime.now());
Comment thread
krusche marked this conversation as resolved.
saveUser(user);
// Web login checks `activated` on every attempt, but the git authentication paths accept a VCS access token or an
// SSH key without consulting account state, so deactivation only takes effect once those credentials are gone.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@

import java.net.URI;
import java.time.Instant;
import java.time.ZonedDateTime;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
Expand Down Expand Up @@ -209,7 +210,13 @@ public void ensureInternalAdminExists(String internalAdminUsername, String inter
internalAdmin.setInternal(true);
}
internalAdmin.setActivated(true);
internalAdmin.setPassword(passwordService.hashPassword(internalAdminPassword));
// The configured password is applied on every startup, so it is compared rather than written blindly: stamping
// credentialsChangedDate unconditionally would end every admin session on every restart, while never stamping it
// leaves sessions from before a rotated configured password renewable past the renewal checkpoint.
if (internalAdmin.getPassword() == null || !passwordService.checkPasswordMatch(internalAdminPassword, internalAdmin.getPassword())) {
internalAdmin.setPassword(passwordService.hashPassword(internalAdminPassword));
internalAdmin.setCredentialsChangedDate(ZonedDateTime.now());
}
// needs to be mutable --> new HashSet<>(Set.of(...))
internalAdmin.setAuthorities(new HashSet<>(Set.of(SUPER_ADMIN_AUTHORITY, new Authority(STUDENT.getAuthority()))));
saveUser(internalAdmin);
Expand Down Expand Up @@ -283,6 +290,8 @@ public Optional<User> completePasswordReset(String newPassword, String key, Cred
user.setPassword(passwordService.hashPassword(newPassword));
user.setResetKey(null);
user.setResetDate(null);
// Stops sessions established before the reset from being extended any further.
user.setCredentialsChangedDate(ZonedDateTime.now());
Comment thread
coderabbitai[bot] marked this conversation as resolved.
saveUser(user);
// A reset is the recovery flow, but forgetting a password is not the same as losing it to someone else, and
// re-enrolling every authenticator and key is a real cost to impose on the common case. So the user decides,
Expand Down Expand Up @@ -584,6 +593,7 @@ public void changePassword(String currentClearTextPassword, String newPassword,
}
String newPasswordHash = passwordService.hashPassword(newPassword);
user.setPassword(newPasswordHash);
user.setCredentialsChangedDate(ZonedDateTime.now());
saveUser(user);
// What else is revoked is the user's decision: only they know whether the old password may have been seen by
// someone else, and that is what decides whether losing their enrolled authenticators and keys is warranted.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@
import de.tum.cit.aet.artemis.core.security.jwt.JWTCookieService;
import de.tum.cit.aet.artemis.core.security.jwt.TokenProvider;
import de.tum.cit.aet.artemis.core.service.ModuleFeatureService;
import de.tum.cit.aet.artemis.core.service.PasskeyTokenRenewalService;
import de.tum.cit.aet.artemis.lti.config.CustomLti13Configurer;

/**
Expand All @@ -73,6 +74,35 @@ public class SecurityConfiguration {

private final JWTCookieService jwtCookieService;

/**
* Instantiated at startup even though it is only called while a session is rotated: this class is a
* {@code @Configuration}, so an eager consumer pulls the service in regardless of the {@code @Lazy} on the service
* itself. Deferring it would need {@code @Lazy} on this parameter or an {@code ObjectProvider}, and
* {@code ArchitectureTest.ensureLazyAnnotationNotUsedOnParameters} forbids the first while
* {@code ensureObjectProviderNotUsedForCircularDependencies} discourages the second, so the one startup bean is
* accounted for in the bean-instantiation threshold instead.
*/
private final PasskeyTokenRenewalService passkeyTokenRenewalService;

/**
* The longest a "remember me" session may live, measured from the original login. Defaults to the thirty days a single
* non-rotating token was valid for before rotation existed, so the maximum session length is unchanged.
* <p>
* This is the only bound on a session, deliberately. Counting extensions instead would make the maximum depend on when
* requests happen to arrive: a rotation fires on the first request after less than half the validity remains, so a
* continuously active session consumes its allowance in half-windows and would end sooner than one that returns just
* before each expiry - the most active users getting the shortest sessions. Measuring from {@code issuedAt} is
* independent of request timing, and {@code issuedAt} is as tamper-proof as any other claim in the signed token.
* <p>
* It also bounds the renewal lookups on its own: with a validity of {@code V} a session can rotate at most
* {@code ceiling / (V / 2)} times, about eight over thirty days with the shipped seven-day validity.
* <p>
* A ceiling is also the only thing that bounds an externally managed session, because a password reset or a
* deactivation performed in LDAP, SAML or OIDC leaves no trace in the local account fields the other renewal checks
* read.
*/
private final long maxSessionLifetimeInSeconds;

private final PasswordService passwordService;

private final TokenProvider tokenProvider;
Expand Down Expand Up @@ -100,14 +130,37 @@ public void validatePasskeyAllowedOriginConfiguration() {
}

public SecurityConfiguration(CorsFilter corsFilter, Optional<CustomLti13Configurer> customLti13Configurer, Optional<ArtemisPasskeyWebAuthnConfigurer> passkeyWebAuthnConfigurer,
PasswordService passwordService, TokenProvider tokenProvider, JWTCookieService jwtCookieService, ModuleFeatureService moduleFeatureService) {
PasswordService passwordService, TokenProvider tokenProvider, JWTCookieService jwtCookieService, PasskeyTokenRenewalService passkeyTokenRenewalService,
ModuleFeatureService moduleFeatureService, @Value("${artemis.user-management.max-session-lifetime-in-seconds:2592000}") long maxSessionLifetimeInSeconds) {
this.corsFilter = corsFilter;
this.customLti13Configurer = customLti13Configurer;
this.passkeyWebAuthnConfigurer = passkeyWebAuthnConfigurer;
this.passwordService = passwordService;
this.tokenProvider = tokenProvider;
this.jwtCookieService = jwtCookieService;
this.passkeyTokenRenewalService = passkeyTokenRenewalService;
this.moduleFeatureService = moduleFeatureService;
this.maxSessionLifetimeInSeconds = requireUsableSessionLifetime(maxSessionLifetimeInSeconds);
}

/**
* Rejects a session lifetime that cannot be turned into milliseconds, at startup rather than per request.
* <p>
* {@link de.tum.cit.aet.artemis.core.security.jwt.JWTFilter} converts this ceiling with
* {@code Math.multiplyExact(sessionCeilingInSeconds, 1000)} while rotating a remember-me token, so a value above
* {@code Long.MAX_VALUE / 1000} would overflow and throw on every renewal, turning a configuration mistake into a
* request-time failure for the users it affects. A value below one second is rejected for the opposite reason: it
* expresses a session that is over before it begins, which is a typo rather than an intent.
*
* @param lifetimeInSeconds the configured lifetime
* @return the same value, once it is known to be usable
*/
private static long requireUsableSessionLifetime(long lifetimeInSeconds) {
if (lifetimeInSeconds < 1 || lifetimeInSeconds > Long.MAX_VALUE / 1000) {
throw new IllegalStateException("artemis.user-management.max-session-lifetime-in-seconds must be between 1 and " + Long.MAX_VALUE / 1000
+ " seconds, so that it can be converted to milliseconds while a session is renewed, but it is " + lifetimeInSeconds);
}
return lifetimeInSeconds;
}

/**
Expand Down Expand Up @@ -366,7 +419,7 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http, Authentication
* @return JWTConfigurer configured with a token provider that generates and validates JWT tokens.
*/
private JWTConfigurer securityConfigurerAdapter() {
return new JWTConfigurer(tokenProvider, jwtCookieService, tokenValidityInSecondsForPasskey);
return new JWTConfigurer(tokenProvider, jwtCookieService, tokenValidityInSecondsForPasskey, passkeyTokenRenewalService, maxSessionLifetimeInSeconds);
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

import de.tum.cit.aet.artemis.core.service.PasskeyTokenRenewalService;

/**
* A custom SecurityConfigurer that integrates JWT authentication into Spring Security's filter chain.
* This configurer is attached to HttpSecurity to apply JWT token verification before processing authentication.
Expand All @@ -16,17 +18,24 @@ public class JWTConfigurer extends AbstractHttpConfigurer<JWTConfigurer, HttpSec

private final long tokenValidityInSecondsForPasskey;

private final PasskeyTokenRenewalService passkeyTokenRenewalService;

private final long maxSessionLifetimeInSeconds;

/**
* Constructs a JWTConfigurer with a specified token provider.
*
* @param tokenProvider the provider responsible for generating and validating JWT tokens.
* @param jwtCookieService the service for JWT cookie management.
* @param tokenValidityInSecondsForPasskey the passkey token validity in seconds.
*/
public JWTConfigurer(TokenProvider tokenProvider, JWTCookieService jwtCookieService, long tokenValidityInSecondsForPasskey) {
public JWTConfigurer(TokenProvider tokenProvider, JWTCookieService jwtCookieService, long tokenValidityInSecondsForPasskey,
PasskeyTokenRenewalService passkeyTokenRenewalService, long maxSessionLifetimeInSeconds) {
this.tokenProvider = tokenProvider;
this.jwtCookieService = jwtCookieService;
this.tokenValidityInSecondsForPasskey = tokenValidityInSecondsForPasskey;
this.passkeyTokenRenewalService = passkeyTokenRenewalService;
this.maxSessionLifetimeInSeconds = maxSessionLifetimeInSeconds;
}

/**
Expand All @@ -38,7 +47,7 @@ public JWTConfigurer(TokenProvider tokenProvider, JWTCookieService jwtCookieServ
*/
@Override
public void configure(HttpSecurity http) {
JWTFilter customFilter = new JWTFilter(tokenProvider, jwtCookieService, tokenValidityInSecondsForPasskey);
JWTFilter customFilter = new JWTFilter(tokenProvider, jwtCookieService, tokenValidityInSecondsForPasskey, passkeyTokenRenewalService, maxSessionLifetimeInSeconds);
// Adds the JWTFilter to the security chain before the UsernamePasswordAuthenticationFilter.
// This ensures that the JWTFilter processes the request first to extract and validate JWTs.
http.addFilterBefore(customFilter, UsernamePasswordAuthenticationFilter.class);
Expand Down
Loading
Loading