Skip to content

Commit 1db8994

Browse files
Merge branch 'master' into DHIS2-21763-te-aggregate-event-value-collapse
2 parents fafa6c0 + 1a39f9b commit 1db8994

12 files changed

Lines changed: 660 additions & 278 deletions

File tree

dhis-2/dhis-api/src/main/java/org/hisp/dhis/user/UserAccountService.java

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,9 @@
3535
import org.hisp.dhis.common.auth.UserInviteParams;
3636
import org.hisp.dhis.common.auth.UserRegistrationParams;
3737
import org.hisp.dhis.feedback.BadRequestException;
38+
import org.hisp.dhis.feedback.ConflictException;
39+
import org.hisp.dhis.feedback.ForbiddenException;
40+
import org.hisp.dhis.feedback.HiddenNotFoundException;
3841

3942
/**
4043
* Service that handles user account activities, e.g. create/update account. The Validation can be
@@ -83,4 +86,49 @@ void validateUserRegistration(RegistrationParams params, String remoteIpAddress)
8386
*/
8487
void validateInvitedUser(RegistrationParams params, String remoteIpAddress)
8588
throws BadRequestException, IOException;
89+
90+
/**
91+
* Initiates the account-recovery flow by sending a restore email to the user, if account recovery
92+
* is enabled. Attempts are throttled per account via the account-recovery lockout.
93+
*
94+
* @param emailOrUsername email address or username identifying the account
95+
* @throws HiddenNotFoundException when the user does not exist or cannot be restored
96+
* @throws ConflictException when recovery is disabled, unconfigured, or sending fails
97+
* @throws ForbiddenException when the account is temporarily locked due to too many attempts
98+
*/
99+
void forgotPassword(String emailOrUsername)
100+
throws HiddenNotFoundException, ConflictException, ForbiddenException;
101+
102+
/**
103+
* Completes the account-recovery flow by setting a new password for the account identified by the
104+
* restore token.
105+
*
106+
* @param token encoded id and restore token pair from the recovery email
107+
* @param newPassword new password, subject to the password policy
108+
* @throws ConflictException when recovery is disabled or the token does not resolve to a user
109+
* @throws BadRequestException when input is missing or the new password is not acceptable
110+
*/
111+
void resetPassword(String token, String newPassword)
112+
throws ConflictException, BadRequestException;
113+
114+
/**
115+
* Self-service change of an <b>expired</b> password. The caller is not logged in, so the method
116+
* is self-guarding: it only proceeds for an expired account whose current password is supplied
117+
* correctly. It deliberately does not establish a session; once the password is changed the
118+
* account is no longer expired, and the caller logs in through the regular login endpoint.
119+
*
120+
* <p>The current-password check runs <b>before</b> the expiry check, so "Account is not expired"
121+
* can only be observed by a caller who already knows the password (no username enumeration), and
122+
* repeated attempts against one account are throttled via the shared account-recovery lockout
123+
* (mirrors {@link #forgotPassword(String)}).
124+
*
125+
* @param username username identifying the account
126+
* @param oldPassword the current (expired) password
127+
* @param newPassword new password, subject to the password policy
128+
* @throws BadRequestException when input is missing, credentials are wrong, the account is not
129+
* expired, or the new password is not acceptable
130+
* @throws ForbiddenException when the account is temporarily locked due to too many attempts
131+
*/
132+
void updateExpiredPassword(String username, String oldPassword, String newPassword)
133+
throws BadRequestException, ForbiddenException;
86134
}

dhis-2/dhis-services/dhis-service-core/src/main/java/org/hisp/dhis/user/DefaultUserAccountService.java

Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,12 +37,19 @@
3737
import java.util.Collection;
3838
import lombok.RequiredArgsConstructor;
3939
import lombok.extern.slf4j.Slf4j;
40+
import org.apache.commons.lang3.StringUtils;
4041
import org.hisp.dhis.common.auth.RegistrationParams;
4142
import org.hisp.dhis.common.auth.UserInviteParams;
4243
import org.hisp.dhis.common.auth.UserRegistrationParams;
4344
import org.hisp.dhis.configuration.ConfigurationService;
45+
import org.hisp.dhis.external.conf.DhisConfigurationProvider;
4446
import org.hisp.dhis.feedback.BadRequestException;
47+
import org.hisp.dhis.feedback.ConflictException;
48+
import org.hisp.dhis.feedback.ErrorCode;
49+
import org.hisp.dhis.feedback.ForbiddenException;
50+
import org.hisp.dhis.feedback.HiddenNotFoundException;
4551
import org.hisp.dhis.organisationunit.OrganisationUnit;
52+
import org.hisp.dhis.security.PasswordManager;
4653
import org.hisp.dhis.security.spring2fa.TwoFactorAuthenticationProvider;
4754
import org.hisp.dhis.security.spring2fa.TwoFactorWebAuthenticationDetails;
4855
import org.hisp.dhis.setting.SystemSettingsProvider;
@@ -62,11 +69,21 @@
6269
@RequiredArgsConstructor
6370
public class DefaultUserAccountService implements UserAccountService {
6471

72+
/**
73+
* Valid bcrypt hash (of a random string, same cost as the configured encoder) used to spend one
74+
* password verification on the unknown-username path of {@link #updateExpiredPassword}, so that
75+
* unknown and known usernames respond in similar time (no enumeration via timing).
76+
*/
77+
private static final String TIMING_EQUALIZATION_HASH =
78+
"$2a$10$4TXauPu06PhCTK8Up3oHi.0Y7SXLeu8ISJ6jq1GYpaaQOsSL5FOxG";
79+
6580
private final UserService userService;
6681
private final ConfigurationService configService;
6782
private final TwoFactorAuthenticationProvider twoFactorAuthProvider;
6883
private final SystemSettingsProvider settingsProvider;
6984
private final PasswordValidationService passwordValidationService;
85+
private final DhisConfigurationProvider configProvider;
86+
private final PasswordManager passwordManager;
7087

7188
@Override
7289
public void validateUserRegistration(RegistrationParams params, String remoteAddress)
@@ -134,6 +151,164 @@ public void confirmUserInvite(UserInviteParams params, HttpServletRequest reques
134151
authenticate(user.getUsername(), params.getPassword(), user.getAuthorities(), request);
135152
}
136153

154+
@Override
155+
@Transactional
156+
public void forgotPassword(String emailOrUsername)
157+
throws HiddenNotFoundException, ConflictException, ForbiddenException {
158+
if (!settingsProvider.getCurrentSettings().getAccountRecoveryEnabled()) {
159+
throw new ConflictException("Account recovery is not enabled");
160+
}
161+
162+
String baseUrl = configProvider.getServerBaseUrl();
163+
if (StringUtils.isEmpty(baseUrl)) {
164+
throw new ConflictException("Server base URL is not configured");
165+
}
166+
167+
User user = getUserByEmailOrUsername(emailOrUsername);
168+
169+
checkRecoveryLock(user.getUsername());
170+
171+
ErrorCode errorCode = userService.validateRestore(user);
172+
if (errorCode != null) {
173+
log.warn("Validate email restore failed: {}", errorCode);
174+
throw new HiddenNotFoundException("Validate failed: " + errorCode);
175+
}
176+
177+
if (!userService.sendRestoreOrInviteMessage(
178+
user, baseUrl, RestoreOptions.RECOVER_PASSWORD_OPTION)) {
179+
throw new ConflictException("Account could not be recovered");
180+
}
181+
182+
log.info("Forgot email was sent to user: {}", user.getUsername());
183+
}
184+
185+
@Override
186+
@Transactional
187+
public void resetPassword(String token, String newPassword)
188+
throws ConflictException, BadRequestException {
189+
if (!settingsProvider.getCurrentSettings().getAccountRecoveryEnabled()) {
190+
throw new ConflictException("Account recovery is not enabled");
191+
}
192+
193+
if (StringUtils.isBlank(token)) {
194+
throw new BadRequestException("Token is required");
195+
}
196+
if (StringUtils.isBlank(newPassword)) {
197+
throw new BadRequestException("New password is required");
198+
}
199+
200+
String[] idAndRestoreToken = userService.decodeEncodedTokens(token);
201+
String idToken = idAndRestoreToken[0];
202+
203+
User user = userService.getUserByIdToken(idToken);
204+
if (user == null || idAndRestoreToken.length < 2 || user.isExternalAuth()) {
205+
throw new ConflictException("Account recovery failed");
206+
}
207+
208+
String restoreToken = idAndRestoreToken[1];
209+
210+
validateNewPassword(user, newPassword);
211+
212+
if (!userService.restore(user, restoreToken, newPassword, RestoreType.RECOVER_PASSWORD)) {
213+
throw new BadRequestException(
214+
"Account could not be restored for user: " + user.getUsername());
215+
}
216+
217+
log.info("Password was reset for user: {}", user.getUsername());
218+
}
219+
220+
@Override
221+
@Transactional
222+
public void updateExpiredPassword(String username, String oldPassword, String newPassword)
223+
throws BadRequestException, ForbiddenException {
224+
if (StringUtils.isBlank(username)
225+
|| StringUtils.isBlank(oldPassword)
226+
|| StringUtils.isBlank(newPassword)) {
227+
throw new BadRequestException("Username, old password and new password are required");
228+
}
229+
230+
User user = userService.getUserByUsername(username);
231+
if (user == null) {
232+
// Generic message on purpose: do not reveal whether the username exists. Spend one password
233+
// verification (result deliberately ignored) so this path takes as long as the known-user
234+
// path below and the timing does not reveal it either.
235+
passwordManager.matches(oldPassword, TIMING_EQUALIZATION_HASH);
236+
throw new BadRequestException("Invalid username or password");
237+
}
238+
239+
// Throttle repeated attempts against a single account (brute-force protection), reusing the
240+
// account-recovery lockout. Registers an attempt and rejects once the threshold is exceeded.
241+
checkRecoveryLock(user.getUsername());
242+
243+
// Caller must know the current (expired) password. Checked before the expiry guard below so
244+
// that "Account is not expired" can only be observed by someone who knows the password.
245+
if (!passwordManager.matches(oldPassword, user.getPassword())) {
246+
throw new BadRequestException("Invalid username or password");
247+
}
248+
249+
// This self-service path is ONLY for expired accounts.
250+
if (userService.userNonExpired(user)) {
251+
throw new BadRequestException("Account is not expired");
252+
}
253+
254+
// The old password was verified against the stored hash above, so plain equality is enough.
255+
if (newPassword.equals(oldPassword)) {
256+
throw new BadRequestException("New password must be different from the old password");
257+
}
258+
259+
validateNewPassword(user, newPassword);
260+
261+
userService.encodeAndSetPassword(user, newPassword);
262+
userService.updateUser(user, new SystemUser());
263+
264+
log.info("Expired password updated for user: {}", user.getUsername());
265+
}
266+
267+
/** Rejects a new password that is equal to the username or fails the password policy. */
268+
private void validateNewPassword(User user, String newPassword) throws BadRequestException {
269+
if (newPassword.trim().equals(user.getUsername().trim())) {
270+
throw new BadRequestException("Password cannot be equal to username");
271+
}
272+
273+
PasswordValidationResult result =
274+
passwordValidationService.validate(
275+
CredentialsInfo.builder()
276+
.username(user.getUsername())
277+
.password(newPassword)
278+
.email(StringUtils.trimToEmpty(user.getEmail()))
279+
.newUser(false)
280+
.build());
281+
if (!result.isValid()) {
282+
throw new BadRequestException(result.getErrorMessage());
283+
}
284+
}
285+
286+
/** Registers a recovery attempt and rejects once the account has exceeded the allowed rate. */
287+
private void checkRecoveryLock(String username) throws ForbiddenException {
288+
if (userService.isRecoveryLocked(username)) {
289+
throw new ForbiddenException(
290+
"The account recovery operation for the given user is temporarily locked due to too "
291+
+ "many calls to this endpoint in the last '"
292+
+ UserConstants.RECOVERY_LOCKOUT_MINS
293+
+ "' minutes. Username:"
294+
+ username);
295+
}
296+
userService.registerRecoveryAttempt(username);
297+
}
298+
299+
private User getUserByEmailOrUsername(String emailOrUsername) throws HiddenNotFoundException {
300+
User user;
301+
if (ValidationUtils.emailIsValid(emailOrUsername)) {
302+
user = userService.getUserByEmail(emailOrUsername);
303+
} else {
304+
user = userService.getUserByUsername(emailOrUsername);
305+
}
306+
if (user == null) {
307+
throw new HiddenNotFoundException("User does not exist: " + emailOrUsername);
308+
}
309+
return user;
310+
}
311+
137312
private User validateRestoreLinkAndToken(UserInviteParams params) throws BadRequestException {
138313
String[] idAndRestoreToken = userService.decodeEncodedTokens(params.getToken());
139314
String idToken = idAndRestoreToken[0];

0 commit comments

Comments
 (0)