Skip to content

Commit 2e96c59

Browse files
authored
General: Require account activation only where a user can activate their own account (#13541)
1 parent 854e431 commit 2e96c59

28 files changed

Lines changed: 762 additions & 132 deletions

docker/nginx/artemis-nginx.conf

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,10 @@ map $rate_limit_exempt $rate_limit_key {
3535

3636
# Rate limit for the login REST call, at most one request every two seconds
3737
limit_req_zone $rate_limit_key zone=loginlimit:10m rate=30r/m;
38+
# Rate limit for the login-options lookup that drives the identifier-first login form. The client calls it once per
39+
# login attempt, right before POST authenticate, so it gets the same budget as the login zone - but in a zone of its
40+
# own, so that consuming it does not halve the number of logins a shared campus address can perform.
41+
limit_req_zone $rate_limit_key zone=loginoptionslimit:10m rate=30r/m;
3842
# Rate limit for account recovery and registration (password reset init/finish, register).
3943
# Stricter than the login zone: these endpoints send mail and mutate credentials, and no
4044
# legitimate client calls them repeatedly.

docker/nginx/artemis-server.conf

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,19 @@ location /api/core/public/authenticate {
5959
limit_req zone=loginlimit burst=3 delay=2;
6060
}
6161

62+
# Login options lookup for the identifier-first login form. NOTE: this must match the real endpoint served by
63+
# PublicAccountResource (@RequestMapping("api/core/public/") + @GetMapping("login-options")). Bounded here as well as by
64+
# the application-level @LimitRequestsPerMinute bucket, so that a flood is dropped at the edge instead of reaching a
65+
# database lookup per request. The zone keys on $rate_limit_key, which maps to $binary_remote_addr (the real TCP peer,
66+
# which the application-level limiter cannot see directly) for every client except an exempted one, which maps to the
67+
# empty key that nginx does not account - see the geo/map blocks in artemis-nginx.conf.
68+
location /api/core/public/login-options {
69+
proxy_pass http://artemis/api/core/public/login-options;
70+
# Same shape as the login block above: the first 2 requests pass immediately, the third waits for a slot, the rest
71+
# are answered 429. A real client sends exactly one of these per login attempt.
72+
limit_req zone=loginoptionslimit burst=3 delay=2;
73+
}
74+
6275
# Account recovery and registration. These send mail and mutate credentials, and no legitimate client
6376
# calls them repeatedly, so they get the stricter zone. These limits key on $binary_remote_addr (the
6477
# real TCP peer) and so complement the application-level @LimitRequestsPerMinute buckets.

documentation/docs/admin/production-setup/security.mdx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -241,12 +241,16 @@ artemis:
241241
enabled: true
242242
account-management-requests-per-minute: 5
243243
authentication-requests-per-minute: 30
244+
login-options-requests-per-minute: 30
244245
```
245246

246247
### Where It Is Enforced
247248

248249
- Registration
250+
- Account activation
249251
- Password reset (request reset link + actual password change)
252+
- Login options lookup (the identifier step of the login form, in its own bucket so that consuming it does not
253+
reduce the number of logins a shared address can perform)
250254
- Username/password login
251255
- WebAuthn authentication
252256
- Git over SSH and HTTP operations

documentation/docs/admin/user-registration.mdx

Lines changed: 254 additions & 12 deletions
Large diffs are not rendered by default.

documentation/sidebar-admin.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ const sidebars: SidebarsConfig = {
3030
'user-registration',
3131
'jenkins-localvc',
3232
'saml2-login-registration',
33+
'oidc-login-registration',
3334
'troubleshooting',
3435
'database-tips',
3536
'known-issues',

src/main/java/de/tum/cit/aet/artemis/account/domain/User.java

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,37 @@ public class User extends AbstractAuditingEntity implements Participant {
104104
@Column(length = 100)
105105
private String email;
106106

107+
/**
108+
* Whether this account may authenticate. Every authentication path enforces it: the internal, SAML2, OIDC and passkey
109+
* providers, and both git paths (HTTPS via {@code LocalVCServletService} and SSH via {@code GitPublickeyAuthenticatorService}).
110+
* <p>
111+
* <b>An account is only ever created unactivated when its own owner is expected to activate it</b>, which requires the
112+
* account to be <b>internal</b> ({@link #isInternal()}). That is what
113+
* {@link de.tum.cit.aet.artemis.account.service.user.UserCreationService#createUser} checks. An externally managed
114+
* account authenticates against the external identity provider, so Artemis has no activation step to offer it: the
115+
* {@link #activationKey} is redeemable only through {@code GET /activate}, which never sends an external account there.
116+
* Creating an external account unactivated therefore produces an account that <em>nothing</em> can ever activate. This
117+
* really happened: the student import created LDAP users unactivated, and they lost repository access as soon as git
118+
* authentication began enforcing this flag.
119+
* <p>
120+
* Being internal is necessary but not by itself sufficient for the key to be redeemable: {@code GET /activate} and the
121+
* mail carrying the key are both gated behind {@code artemis.user-management.registration.enabled}, so on an instance
122+
* with self-registration disabled even an internal account has no way to redeem one. Creation is deliberately
123+
* <em>not</em> narrowed to match, because the LTI launch also creates an internal account through the factory and reads
124+
* this flag as its own record of whether it still owes the account holder the generated password.
125+
* <p>
126+
* Only three kinds of writes set this to {@code false}, and only the first is the activation workflow:
127+
* <ol>
128+
* <li><b>awaiting activation</b> - {@code UserCreationService.createUser} for an internal account, and
129+
* {@code UserService.registerUser}, whose accounts are always internal. Paired with a non-null
130+
* {@link #activationKey}.</li>
131+
* <li><b>deliberate deactivation</b> - {@code UserCreationService.deactivateUser} and the admin edit form. Applies to
132+
* any account regardless of type, and never sets an activation key.</li>
133+
* <li><b>soft deletion</b> - {@code UserService.anonymizeUser}, alongside {@link #deleted}.</li>
134+
* </ol>
135+
* The presence of an {@link #activationKey} consequently distinguishes (1) from (2), which is what made it possible to
136+
* repair the affected rows without touching accounts an admin had deactivated on purpose.
137+
*/
107138
@NonNull
108139
@Column(nullable = false)
109140
private boolean activated = false;
@@ -136,6 +167,15 @@ public class User extends AbstractAuditingEntity implements Participant {
136167
@Column(name = "image_url", length = 256)
137168
private String imageUrl;
138169

170+
/**
171+
* One-time key a user redeems through {@code GET /activate} to activate their own account. Only ever set on an
172+
* <b>internal</b> account, and only together with {@code activated = false} - see {@link #activated} for why an
173+
* externally managed account must never be given one, and for how the key's presence tells an account awaiting
174+
* activation apart from one an admin deactivated.
175+
* <p>
176+
* Cleared by every write that activates the account, so the two fields stay consistent: {@code activateUser} for the
177+
* activation workflow and the administrative action, and {@code setRandomPasswordAndReturn} for the LTI launch.
178+
*/
139179
@Size(max = 20)
140180
@Column(name = "activation_key", length = 20)
141181
@JsonIgnore

src/main/java/de/tum/cit/aet/artemis/account/security/LdapAuthenticationProvider.java

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919

2020
import de.tum.cit.aet.artemis.account.config.LdapEnabled;
2121
import de.tum.cit.aet.artemis.account.domain.User;
22+
import de.tum.cit.aet.artemis.account.exception.UserNotActivatedException;
2223
import de.tum.cit.aet.artemis.account.repository.UserRepository;
2324
import de.tum.cit.aet.artemis.account.service.ldap.LdapUserDto;
2425
import de.tum.cit.aet.artemis.account.service.ldap.LdapUserService;
@@ -105,8 +106,15 @@ private User authenticateUser(Authentication authentication) throws BadCredentia
105106

106107
// update the user details from ldapUserDto (because they might have changed, e.g. when the user changes the name)
107108
if (optionalUser.isPresent()) {
108-
// TODO: make sure the user is not deactivated in the meantime
109-
return saveUserIfNeeded(optionalUser.get(), ldapUserDto);
109+
// Checked after the LDAP credentials have been verified, so an unauthenticated caller cannot use the outcome to
110+
// learn anything about an account. This provider was the only one that did not consult the flag, which is why an
111+
// account left unactivated by the student import could sign in here while being refused everywhere else.
112+
User existingUser = optionalUser.get();
113+
if (!existingUser.getActivated() || existingUser.isDeleted()) {
114+
log.warn("Login attempt for user {} whose account is deactivated or deleted", existingUser.getLogin());
115+
throw new UserNotActivatedException("User " + existingUser.getLogin() + " was not activated");
116+
}
117+
return saveUserIfNeeded(existingUser, ldapUserDto);
110118
}
111119
else {
112120
// this handles the case that the user does not exist in the Artemis database yet (i.e. first time user login)
@@ -127,10 +135,8 @@ private User createUser(LdapUserDto ldapUserDto) {
127135

128136
newUser.setAuthorities(authorityService.buildAuthorities(newUser));
129137

130-
if (!newUser.getActivated()) {
131-
newUser.setActivated(true);
132-
newUser.setActivationKey(null);
133-
}
138+
// No activation handling here: userCreationService.createUser already creates an external user activated, because such a user has
139+
// no way to redeem an activation key. This used to re-activate the user to undo what the factory did unconditionally.
134140
log.info("New LDAP user {} created in Artemis", ldapUserDto.getLogin());
135141
return userCreationService.saveUser(newUser);
136142
}

src/main/java/de/tum/cit/aet/artemis/account/service/LoginOptionsService.java

Lines changed: 25 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -13,13 +13,16 @@
1313
import de.tum.cit.aet.artemis.account.dto.LoginOptionsDTO;
1414
import de.tum.cit.aet.artemis.account.dto.LoginOptionsDTO.LoginMethod;
1515
import de.tum.cit.aet.artemis.account.repository.UserRepository;
16-
import de.tum.cit.aet.artemis.account.service.ldap.LdapUserDto;
17-
import de.tum.cit.aet.artemis.account.service.ldap.LdapUserService;
1816
import de.tum.cit.aet.artemis.core.security.SecurityUtils;
1917

2018
/**
21-
* Service responsible for determining the appropriate login options (such as password, OIDC, or SAML2)
22-
* for a user based on their identifier (login or email).
19+
* Determines which login option (password, OIDC, or SAML2) the login form should offer for a given identifier, so that the
20+
* identifier-first form can either show a password field or send the user to the configured identity provider.
21+
* <p>
22+
* The decision is made from local account state alone. This backs an unauthenticated endpoint, so its answer must be
23+
* derivable from what the caller already supplies: it deliberately does not consult the configured directory, which would
24+
* both make the response depend on whether the identifier exists there and let an unauthenticated caller drive queries
25+
* against it.
2326
*/
2427
@Profile(PROFILE_CORE)
2528
@Service
@@ -28,8 +31,6 @@ public class LoginOptionsService {
2831

2932
private final UserRepository userRepository;
3033

31-
private final Optional<LdapUserService> ldapUserService;
32-
3334
@Value("${artemis.user-management.oidc.enabled:false}")
3435
private boolean oidcEnabled;
3536

@@ -42,13 +43,19 @@ public class LoginOptionsService {
4243
@Value("${info.saml2.buttonLabel:TUM Login}")
4344
private String samlDisplayName;
4445

45-
public LoginOptionsService(UserRepository userRepository, Optional<LdapUserService> ldapUserService) {
46+
public LoginOptionsService(UserRepository userRepository) {
4647
this.userRepository = userRepository;
47-
this.ldapUserService = ldapUserService;
4848
}
4949

5050
/**
5151
* Determines which login method the user should use based on their identifier (login or email).
52+
* <p>
53+
* An internal account is the only kind that authenticates against a password stored in Artemis, so it is the only case
54+
* answered with the password form. Everything else - an externally managed account, and an identifier this instance has
55+
* never seen - is sent to the external provider, which is also where a first-time user gets provisioned. Those two are
56+
* answered identically on purpose, so the response does not distinguish a known identifier from an unknown one.
57+
* <p>
58+
* Falls back to the password form when no external provider is configured, and for a blank identifier.
5259
*
5360
* @param emailOrLogin the username or email address entered by the user
5461
* @return the LoginOptionsDTO containing the determined login method and the display name of the provider
@@ -61,30 +68,17 @@ public LoginOptionsDTO getLoginOptions(String emailOrLogin) {
6168
boolean isEmail = SecurityUtils.isEmail(sanitizedInput);
6269
// only project the internal flag instead of loading the whole user entity: empty means the user is not in the database
6370
Optional<Boolean> internalFlag = isEmail ? userRepository.isInternalUserByEmailIgnoreCase(sanitizedInput) : userRepository.isInternalUserByLogin(sanitizedInput);
64-
if (internalFlag.isPresent()) {
65-
if (internalFlag.get()) {
66-
return new LoginOptionsDTO(LoginMethod.PASSWORD, null);
67-
}
68-
else {
69-
return getExternalUser();
70-
}
71-
}
72-
if (ldapUserService.isPresent()) {
73-
Optional<LdapUserDto> ldapUser = isEmail ? ldapUserService.get().findByAnyEmail(sanitizedInput) : ldapUserService.get().findByLogin(sanitizedInput);
74-
// if user has a university account
75-
if (ldapUser.isPresent()) {
76-
return getExternalUser();
77-
}
78-
// if not: this is an internal user
79-
else {
80-
return new LoginOptionsDTO(LoginMethod.PASSWORD, null);
81-
}
82-
}
83-
// If user is new and ldap is disabled - provide the SSO authentication option
84-
if (oidcEnabled || samlEnabled) {
85-
return getExternalUser();
71+
// An internal account is the only kind that authenticates against a password stored in Artemis, so it is the only case that
72+
// needs the password form. Everything else - an externally managed account, and an identifier this instance has never seen -
73+
// is sent to the external provider, which is also where a first-time user gets provisioned.
74+
//
75+
// The two are answered identically on purpose. This endpoint is unauthenticated, so its answer must be derivable from what the
76+
// caller already knows; it deliberately does not consult the configured directory, which would both make the response depend on
77+
// whether the identifier exists there and let an unauthenticated caller drive queries against it.
78+
if (internalFlag.isPresent() && internalFlag.get()) {
79+
return new LoginOptionsDTO(LoginMethod.PASSWORD, null);
8680
}
87-
return new LoginOptionsDTO(LoginMethod.PASSWORD, null);
81+
return getExternalUser();
8882
}
8983

9084
/**

0 commit comments

Comments
 (0)