Skip to content

Commit

Permalink
Merge pull request #18 from OpenLMIS/OIS-57
Browse files Browse the repository at this point in the history
OIS-57: Introduce password reset lockout
  • Loading branch information
pwargulak authored Sep 30, 2024
2 parents a030639 + a1d36e2 commit f6d5d6d
Show file tree
Hide file tree
Showing 14 changed files with 544 additions and 0 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
/*
* This program is part of the OpenLMIS logistics management information system platform software.
* Copyright © 2017 VillageReach
*
* This program is free software: you can redistribute it and/or modify it under the terms
* of the GNU Affero General Public License as published by the Free Software Foundation, either
* version 3 of the License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY;
* without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
* See the GNU Affero General Public License for more details. You should have received a copy of
* the GNU Affero General Public License along with this program. If not, see
* http://www.gnu.org/licenses.  For additional information contact info@OpenLMIS.org.
*/

package org.openlmis.auth.repository;

import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotNull;

import java.util.UUID;
import javax.persistence.EntityManager;
import javax.persistence.PersistenceException;
import org.junit.Test;
import org.openlmis.auth.domain.PasswordResetRegistry;
import org.openlmis.auth.domain.User;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.repository.CrudRepository;

public class PasswordResetRegistryRepositoryIntegrationTest
extends BaseCrudRepositoryIntegrationTest<PasswordResetRegistry> {

@Autowired
private PasswordResetRegistryRepository passwordResetRegistryRepository;

@Autowired
private UserRepository userRepository;

@Autowired
private EntityManager entityManager;

@Test
public void shouldFindRegistryByUser() throws Exception {
User user = userRepository.save(generateUser());
PasswordResetRegistry registry = passwordResetRegistryRepository.save(generateInstance(user));

PasswordResetRegistry result = passwordResetRegistryRepository.findByUser(user).get();

assertNotNull(result);
assertEquals(registry.getId(), result.getId());
}

@Test(expected = PersistenceException.class)
public void shouldThrowExceptionOnCreatingRegistryWithSameUser() throws Exception {
User user = userRepository.save(generateUser());

passwordResetRegistryRepository.save(generateInstance(user));
passwordResetRegistryRepository.save(generateInstance(user));

entityManager.flush();
}

@Override
CrudRepository<PasswordResetRegistry, UUID> getRepository() {
return passwordResetRegistryRepository;
}

@Override
PasswordResetRegistry generateInstance() throws Exception {
return new PasswordResetRegistry(userRepository.save(generateUser()));
}

PasswordResetRegistry generateInstance(User user) throws Exception {
return new PasswordResetRegistry(user);
}

private User generateUser() {
User user = new User();
user.setUsername("user" + getNextInstanceNumber());
user.setEnabled(true);
return user;
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@
import org.openlmis.auth.i18n.MessageKeys;
import org.openlmis.auth.repository.PasswordResetTokenRepository;
import org.openlmis.auth.repository.UserRepository;
import org.openlmis.auth.service.PasswordResetRegistryService;
import org.openlmis.auth.service.PermissionService;
import org.openlmis.auth.service.notification.NotificationService;
import org.openlmis.auth.service.notification.UserContactDetailsDto;
Expand Down Expand Up @@ -105,6 +106,9 @@ public class UserControllerIntegrationTest extends BaseWebIntegrationTest {
@MockBean
private UserContactDetailsNotificationService userContactDetailsNotificationService;

@MockBean
private PasswordResetRegistryService passwordResetRegistryService;

private User user;
private UserDto userDto = new UserDto();
private UserContactDetailsDto userContactDetailsDto = new UserContactDetailsDto();
Expand Down Expand Up @@ -310,6 +314,8 @@ public void shouldCreateNewTokenAfterEachForgotPasswordRequest() {
PasswordResetToken token1 = new PasswordResetToken();
token1.setUser(user1);
token1.setExpiryDate(ZonedDateTime.now().plusHours(TOKEN_VALIDITY_HOURS));
willDoNothing().given(passwordResetRegistryService)
.checkPasswordResetLimit(any(User.class));

passwordResetTokenRepository.save(token1);

Expand Down
75 changes: 75 additions & 0 deletions src/main/java/org/openlmis/auth/domain/PasswordResetRegistry.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
/*
* This program is part of the OpenLMIS logistics management information system platform software.
* Copyright © 2017 VillageReach
*
* This program is free software: you can redistribute it and/or modify it under the terms
* of the GNU Affero General Public License as published by the Free Software Foundation, either
* version 3 of the License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY;
* without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
* See the GNU Affero General Public License for more details. You should have received a copy of
* the GNU Affero General Public License along with this program. If not, see
* http://www.gnu.org/licenses.  For additional information contact info@OpenLMIS.org.
*/

package org.openlmis.auth.domain;

import java.time.ZonedDateTime;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.JoinColumn;
import javax.persistence.OneToOne;
import javax.persistence.Table;
import lombok.AllArgsConstructor;
import lombok.EqualsAndHashCode;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;

@Entity
@Table(name = "password_reset_registries")
@NoArgsConstructor
@AllArgsConstructor
@Getter
@Setter
@EqualsAndHashCode(callSuper = true)
public class PasswordResetRegistry extends BaseEntity {

@OneToOne
@JoinColumn(name = "userId", nullable = false, unique = true)
private User user;

@Column(nullable = false, columnDefinition = "timestamp with time zone")
private ZonedDateTime lastAttemptDate = ZonedDateTime.now();

@Column(nullable = false, columnDefinition = "timestamp with time zone")
private ZonedDateTime lastCounterResetDate = ZonedDateTime.now();

@Column(name = "attemptcounter")
private Integer attemptCounter = 0;

@Column(name = "blocked")
private Boolean blocked;

public PasswordResetRegistry(User user) {
this.user = user;
}

/**
* Resets the attempt counter and updates the last counter reset date and last attempt date.
*/
public void resetCounter() {
this.setAttemptCounter(0);
this.setLastCounterResetDate(ZonedDateTime.now());
}

/**
* Increments the attempt counter and updates the last attempt date.
*/
public void incrementCounter() {
this.setAttemptCounter(this.getAttemptCounter() + 1);
this.setLastAttemptDate(ZonedDateTime.now());
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
/*
* This program is part of the OpenLMIS logistics management information system platform software.
* Copyright © 2017 VillageReach
*
* This program is free software: you can redistribute it and/or modify it under the terms
* of the GNU Affero General Public License as published by the Free Software Foundation, either
* version 3 of the License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY;
* without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
* See the GNU Affero General Public License for more details. You should have received a copy of
* the GNU Affero General Public License along with this program. If not, see
* http://www.gnu.org/licenses.  For additional information contact info@OpenLMIS.org.
*/

package org.openlmis.auth.exception;

public class TooManyRequestsMessageException extends BaseMessageException {

public TooManyRequestsMessageException(String messageKey) {
super(messageKey);
}

}
2 changes: 2 additions & 0 deletions src/main/java/org/openlmis/auth/i18n/MessageKeys.java
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ public abstract class MessageKeys {
public static final String ERROR_SEND_REQUEST = ERROR + ".sendRequest";
public static final String ERROR_IO = ERROR_PREFIX + ".io";

public static final String ERROR_TOO_MANY_REQUESTS = ERROR_PREFIX + ".tooManyRequests";

public static final String ERROR_TOKEN_INVALID = ERROR_PREFIX + ".token.invalid";
public static final String ERROR_TOKEN_EXPIRED = ERROR_PREFIX + ".token.expired";
public static final String ERROR_TOKEN_REQUIRED = ERROR_PREFIX + ".token.required";
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
/*
* This program is part of the OpenLMIS logistics management information system platform software.
* Copyright © 2017 VillageReach
*
* This program is free software: you can redistribute it and/or modify it under the terms
* of the GNU Affero General Public License as published by the Free Software Foundation, either
* version 3 of the License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY;
* without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
* See the GNU Affero General Public License for more details. You should have received a copy of
* the GNU Affero General Public License along with this program. If not, see
* http://www.gnu.org/licenses.  For additional information contact info@OpenLMIS.org.
*/

package org.openlmis.auth.repository;

import java.util.Optional;
import java.util.UUID;
import org.openlmis.auth.domain.PasswordResetRegistry;
import org.openlmis.auth.domain.User;
import org.springframework.data.repository.CrudRepository;

public interface PasswordResetRegistryRepository
extends CrudRepository<PasswordResetRegistry, UUID> {

Optional<PasswordResetRegistry> findByUser(User user);

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
/*
* This program is part of the OpenLMIS logistics management information system platform software.
* Copyright © 2017 VillageReach
*
* This program is free software: you can redistribute it and/or modify it under the terms
* of the GNU Affero General Public License as published by the Free Software Foundation, either
* version 3 of the License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY;
* without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
* See the GNU Affero General Public License for more details. You should have received a copy of
* the GNU Affero General Public License along with this program. If not, see
* http://www.gnu.org/licenses.  For additional information contact info@OpenLMIS.org.
*/

package org.openlmis.auth.service;

import static org.openlmis.auth.i18n.MessageKeys.ERROR_TOO_MANY_REQUESTS;

import java.time.Duration;
import java.time.ZonedDateTime;
import java.util.Optional;
import org.openlmis.auth.domain.PasswordResetRegistry;
import org.openlmis.auth.domain.User;
import org.openlmis.auth.exception.TooManyRequestsMessageException;
import org.openlmis.auth.repository.PasswordResetRegistryRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;

@Service
public class PasswordResetRegistryService {

@Value("${password.reset.maxAttempts}")
private int maxAttempt;

@Value("${password.reset.maxTimeForAttempts}")
private long maxTimeForAttempts;

@Value("${password.reset.lockoutTime}")
private long lockoutTime;

@Autowired
private PasswordResetRegistryRepository passwordResetRegistryRepository;

/**
* Checks whether the user has exceeded the limit of attempts to send a password reset request.
*
* @param user @param user the User attempting a password reset
*/
public void checkPasswordResetLimit(User user) {
Optional<PasswordResetRegistry> registryOpt = passwordResetRegistryRepository.findByUser(user);
ZonedDateTime now = ZonedDateTime.now();

PasswordResetRegistry registry;
if (registryOpt.isPresent()) {
registry = registryOpt.get();

if (Boolean.TRUE.equals(registry.getBlocked())) {
long secondsSinceLastAttempt =
Duration.between(registry.getLastAttemptDate(), now).getSeconds();
if (secondsSinceLastAttempt < lockoutTime) {
throw new TooManyRequestsMessageException(ERROR_TOO_MANY_REQUESTS);
} else {
registry.resetCounter();
registry.setBlocked(false);
}
}

long secondsSinceFirstAttempt =
Duration.between(registry.getLastCounterResetDate(), now).getSeconds();
if (secondsSinceFirstAttempt > maxTimeForAttempts) {
registry.resetCounter();
}

registry.incrementCounter();

if (registry.getAttemptCounter() >= maxAttempt) {
registry.setBlocked(true);
}
} else {
PasswordResetRegistry newRegistry = new PasswordResetRegistry(user);
newRegistry.setAttemptCounter(1);
newRegistry.setLastAttemptDate(now);
newRegistry.setLastCounterResetDate(now);

registry = newRegistry;
}
passwordResetRegistryRepository.save(registry);
}

}
5 changes: 5 additions & 0 deletions src/main/java/org/openlmis/auth/web/UserController.java
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
import org.openlmis.auth.repository.PasswordResetTokenRepository;
import org.openlmis.auth.repository.UserRepository;
import org.openlmis.auth.service.PasswordResetNotifier;
import org.openlmis.auth.service.PasswordResetRegistryService;
import org.openlmis.auth.service.PermissionService;
import org.openlmis.auth.service.UserService;
import org.openlmis.auth.service.notification.UserContactDetailsDto;
Expand Down Expand Up @@ -105,6 +106,9 @@ public class UserController {
@Autowired
private UserContactDetailsNotificationService userContactDetailsNotificationService;

@Autowired
private PasswordResetRegistryService passwordResetRegistryService;

@InitBinder
protected void initBinder(WebDataBinder binder) {
binder.setValidator(this.validator);
Expand Down Expand Up @@ -215,6 +219,7 @@ public void forgotPassword(@RequestParam(value = "email") String email) {
LOGGER.error("User with ID {} does not exist.", found.get(0).getReferenceDataUserId(),
new ValidationMessageException(USER_NOT_FOUND));
} else {
passwordResetRegistryService.checkPasswordResetLimit(optionalUser.get());
passwordResetNotifier.sendNotification(optionalUser.get());
}
}
Expand Down
15 changes: 15 additions & 0 deletions src/main/java/org/openlmis/auth/web/WebErrorHandling.java
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
import org.openlmis.auth.exception.NotFoundException;
import org.openlmis.auth.exception.PermissionMessageException;
import org.openlmis.auth.exception.ServerException;
import org.openlmis.auth.exception.TooManyRequestsMessageException;
import org.openlmis.auth.exception.ValidationMessageException;
import org.openlmis.auth.util.Message;
import org.springframework.http.HttpStatus;
Expand Down Expand Up @@ -131,4 +132,18 @@ public LocalizedMessageDto handleExternalApiException(ExternalApiException ex) {
return ex.getMessageLocalized();
}

/**
* Handles too many requests exception exception.
*
* @param ex the too many requests exception
* @return the user-oriented error message.
*/
@ExceptionHandler(TooManyRequestsMessageException.class)
@ResponseStatus(HttpStatus.TOO_MANY_REQUESTS)
@ResponseBody
public Message.LocalizedMessage handleTooManyRequestsMessageException(
TooManyRequestsMessageException ex) {
return getLocalizedMessage(ex.asMessage());
}

}
Loading

0 comments on commit f6d5d6d

Please sign in to comment.