|
37 | 37 | import java.util.Collection; |
38 | 38 | import lombok.RequiredArgsConstructor; |
39 | 39 | import lombok.extern.slf4j.Slf4j; |
| 40 | +import org.apache.commons.lang3.StringUtils; |
40 | 41 | import org.hisp.dhis.common.auth.RegistrationParams; |
41 | 42 | import org.hisp.dhis.common.auth.UserInviteParams; |
42 | 43 | import org.hisp.dhis.common.auth.UserRegistrationParams; |
43 | 44 | import org.hisp.dhis.configuration.ConfigurationService; |
| 45 | +import org.hisp.dhis.external.conf.DhisConfigurationProvider; |
44 | 46 | 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; |
45 | 51 | import org.hisp.dhis.organisationunit.OrganisationUnit; |
| 52 | +import org.hisp.dhis.security.PasswordManager; |
46 | 53 | import org.hisp.dhis.security.spring2fa.TwoFactorAuthenticationProvider; |
47 | 54 | import org.hisp.dhis.security.spring2fa.TwoFactorWebAuthenticationDetails; |
48 | 55 | import org.hisp.dhis.setting.SystemSettingsProvider; |
|
62 | 69 | @RequiredArgsConstructor |
63 | 70 | public class DefaultUserAccountService implements UserAccountService { |
64 | 71 |
|
| 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 | + |
65 | 80 | private final UserService userService; |
66 | 81 | private final ConfigurationService configService; |
67 | 82 | private final TwoFactorAuthenticationProvider twoFactorAuthProvider; |
68 | 83 | private final SystemSettingsProvider settingsProvider; |
69 | 84 | private final PasswordValidationService passwordValidationService; |
| 85 | + private final DhisConfigurationProvider configProvider; |
| 86 | + private final PasswordManager passwordManager; |
70 | 87 |
|
71 | 88 | @Override |
72 | 89 | public void validateUserRegistration(RegistrationParams params, String remoteAddress) |
@@ -134,6 +151,164 @@ public void confirmUserInvite(UserInviteParams params, HttpServletRequest reques |
134 | 151 | authenticate(user.getUsername(), params.getPassword(), user.getAuthorities(), request); |
135 | 152 | } |
136 | 153 |
|
| 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 | + |
137 | 312 | private User validateRestoreLinkAndToken(UserInviteParams params) throws BadRequestException { |
138 | 313 | String[] idAndRestoreToken = userService.decodeEncodedTokens(params.getToken()); |
139 | 314 | String idToken = idAndRestoreToken[0]; |
|
0 commit comments