From c5e572f62349664843881f5e633d9a21839073d4 Mon Sep 17 00:00:00 2001 From: Szymon Radziszewski Date: Thu, 19 Sep 2024 12:16:37 +0200 Subject: [PATCH] OIS-46: Added login lockout mechanism --- ...ationAttemptRepositoryIntegrationTest.java | 86 +++++++++++++++++++ .../UnsuccessfulAuthenticationAttempt.java | 64 ++++++++++++++ .../java/org/openlmis/auth/domain/User.java | 15 ++-- .../java/org/openlmis/auth/dto/UserDto.java | 1 + ...essfulAuthenticationAttemptRepository.java | 28 ++++++ .../security/OlmisAuthenticationProvider.java | 81 +++++++++++++++++ .../auth/security/SecurityConfiguration.java | 2 +- src/main/resources/application.properties | 3 + ...ccessful_authentication_attempts_table.sql | 8 ++ ...__add_locked_out_column_for_auth_users.sql | 2 + src/main/resources/schemas/user.json | 4 + ...UnsuccessfulAuthenticationAttemptTest.java | 64 ++++++++++++++ .../org/openlmis/auth/domain/UserTest.java | 1 + 13 files changed, 352 insertions(+), 7 deletions(-) create mode 100644 src/integration-test/java/org/openlmis/auth/repository/UnsuccessfulAuthenticationAttemptRepositoryIntegrationTest.java create mode 100644 src/main/java/org/openlmis/auth/domain/UnsuccessfulAuthenticationAttempt.java create mode 100644 src/main/java/org/openlmis/auth/repository/UnsuccessfulAuthenticationAttemptRepository.java create mode 100644 src/main/java/org/openlmis/auth/security/OlmisAuthenticationProvider.java create mode 100644 src/main/resources/db/migration/20240917224024116__add_unsuccessful_authentication_attempts_table.sql create mode 100644 src/main/resources/db/migration/20240917224340535__add_locked_out_column_for_auth_users.sql create mode 100644 src/test/java/org/openlmis/auth/domain/UnsuccessfulAuthenticationAttemptTest.java diff --git a/src/integration-test/java/org/openlmis/auth/repository/UnsuccessfulAuthenticationAttemptRepositoryIntegrationTest.java b/src/integration-test/java/org/openlmis/auth/repository/UnsuccessfulAuthenticationAttemptRepositoryIntegrationTest.java new file mode 100644 index 0000000..f3c66e2 --- /dev/null +++ b/src/integration-test/java/org/openlmis/auth/repository/UnsuccessfulAuthenticationAttemptRepositoryIntegrationTest.java @@ -0,0 +1,86 @@ +/* + * 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.UnsuccessfulAuthenticationAttempt; +import org.openlmis.auth.domain.User; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.repository.CrudRepository; + +public class UnsuccessfulAuthenticationAttemptRepositoryIntegrationTest + extends BaseCrudRepositoryIntegrationTest { + + @Autowired + private UnsuccessfulAuthenticationAttemptRepository unsuccessfulAuthenticationAttemptRepository; + + @Autowired + private UserRepository userRepository; + + @Autowired + private EntityManager entityManager; + + @Test + public void shouldFindAttemptByUserId() throws Exception { + User user = userRepository.save(generateUser()); + UnsuccessfulAuthenticationAttempt attempt = + unsuccessfulAuthenticationAttemptRepository.save(generateInstance(user)); + + UnsuccessfulAuthenticationAttempt result = + unsuccessfulAuthenticationAttemptRepository.findByUserId(user.getId()).get(); + + assertNotNull(result); + assertEquals(attempt.getId(), result.getId()); + } + + @Test(expected = PersistenceException.class) + public void shouldThrowExceptionOnCreatingAttemptsWithSameUser() throws Exception { + User user = userRepository.save(generateUser()); + + unsuccessfulAuthenticationAttemptRepository.save(generateInstance(user)); + unsuccessfulAuthenticationAttemptRepository.save(generateInstance(user)); + + entityManager.flush(); + } + + @Override + CrudRepository getRepository() { + return unsuccessfulAuthenticationAttemptRepository; + } + + @Override + UnsuccessfulAuthenticationAttempt generateInstance() throws Exception { + return new UnsuccessfulAuthenticationAttempt(userRepository.save(generateUser())); + } + + UnsuccessfulAuthenticationAttempt generateInstance(User user) throws Exception { + return new UnsuccessfulAuthenticationAttempt(user); + } + + private User generateUser() { + User user = new User(); + user.setUsername("user" + getNextInstanceNumber()); + user.setEnabled(true); + return user; + } + +} diff --git a/src/main/java/org/openlmis/auth/domain/UnsuccessfulAuthenticationAttempt.java b/src/main/java/org/openlmis/auth/domain/UnsuccessfulAuthenticationAttempt.java new file mode 100644 index 0000000..ed8edf7 --- /dev/null +++ b/src/main/java/org/openlmis/auth/domain/UnsuccessfulAuthenticationAttempt.java @@ -0,0 +1,64 @@ +/* + * 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 = "unsuccessful_authentication_attempts") +@NoArgsConstructor +@AllArgsConstructor +@Getter +@Setter +@EqualsAndHashCode(callSuper = true) +public class UnsuccessfulAuthenticationAttempt extends BaseEntity { + + @OneToOne + @JoinColumn(name = "userId", nullable = false, unique = true) + private User user; + + @Column(nullable = false, columnDefinition = "timestamp with time zone") + private ZonedDateTime lastUnsuccessfulAuthenticationAttemptDate = ZonedDateTime.now(); + + @Column(name = "attemptcounter") + private Integer attemptCounter = 0; + + public UnsuccessfulAuthenticationAttempt(User user) { + this(); + this.user = user; + } + + public void incrementCounter() { + setAttemptCounter(getAttemptCounter() + 1); + setLastUnsuccessfulAuthenticationAttemptDate(ZonedDateTime.now()); + } + + public void resetCounter() { + setAttemptCounter(0); + setLastUnsuccessfulAuthenticationAttemptDate(ZonedDateTime.now()); + } + +} diff --git a/src/main/java/org/openlmis/auth/domain/User.java b/src/main/java/org/openlmis/auth/domain/User.java index 992f0a7..5ec2338 100644 --- a/src/main/java/org/openlmis/auth/domain/User.java +++ b/src/main/java/org/openlmis/auth/domain/User.java @@ -31,27 +31,26 @@ import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.util.StringUtils; +@Getter +@Setter @Entity @Table(name = "auth_users") @JsonIgnoreProperties(value = { "authorities" }, ignoreUnknown = true) public class User extends BaseEntity implements UserDetails { private static final BCryptPasswordEncoder ENCODER = new BCryptPasswordEncoder(); - @Getter - @Setter @Column(nullable = false, unique = true) private String username; - @Getter - @Setter @Column private String password; - @Getter - @Setter @Column private Boolean enabled; + @Column + private boolean lockedOut; + /** * Creates new instance of {@link User} based on passed data. */ @@ -69,6 +68,7 @@ public static User newInstance(Importer importer) { public void updateFrom(Importer importer) { username = importer.getUsername(); enabled = importer.getEnabled(); + lockedOut = importer.isLockedOut(); String newPassword = importer.getPassword(); if (StringUtils.hasText(newPassword)) { @@ -109,6 +109,7 @@ public void export(Exporter exporter) { exporter.setUsername(username); exporter.setPassword(password); exporter.setEnabled(enabled); + exporter.setLockedOut(lockedOut); } @@ -122,6 +123,7 @@ public interface Importer { Boolean getEnabled(); + boolean isLockedOut(); } public interface Exporter { @@ -134,6 +136,7 @@ public interface Exporter { void setEnabled(Boolean enabled); + void setLockedOut(boolean lockedOut); } } diff --git a/src/main/java/org/openlmis/auth/dto/UserDto.java b/src/main/java/org/openlmis/auth/dto/UserDto.java index 320b450..2f45045 100644 --- a/src/main/java/org/openlmis/auth/dto/UserDto.java +++ b/src/main/java/org/openlmis/auth/dto/UserDto.java @@ -35,4 +35,5 @@ public final class UserDto implements User.Importer, User.Exporter { private String username; private String password; private Boolean enabled; + private boolean lockedOut; } diff --git a/src/main/java/org/openlmis/auth/repository/UnsuccessfulAuthenticationAttemptRepository.java b/src/main/java/org/openlmis/auth/repository/UnsuccessfulAuthenticationAttemptRepository.java new file mode 100644 index 0000000..924706a --- /dev/null +++ b/src/main/java/org/openlmis/auth/repository/UnsuccessfulAuthenticationAttemptRepository.java @@ -0,0 +1,28 @@ +/* + * 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.UnsuccessfulAuthenticationAttempt; +import org.springframework.data.repository.CrudRepository; + +public interface UnsuccessfulAuthenticationAttemptRepository + extends CrudRepository { + + Optional findByUserId(UUID userId); + +} diff --git a/src/main/java/org/openlmis/auth/security/OlmisAuthenticationProvider.java b/src/main/java/org/openlmis/auth/security/OlmisAuthenticationProvider.java new file mode 100644 index 0000000..901772e --- /dev/null +++ b/src/main/java/org/openlmis/auth/security/OlmisAuthenticationProvider.java @@ -0,0 +1,81 @@ +/* + * 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.security; + +import java.time.Duration; +import java.time.ZonedDateTime; +import org.openlmis.auth.domain.UnsuccessfulAuthenticationAttempt; +import org.openlmis.auth.domain.User; +import org.openlmis.auth.repository.UnsuccessfulAuthenticationAttemptRepository; +import org.openlmis.auth.repository.UserRepository; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.authentication.LockedException; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.authentication.dao.DaoAuthenticationProvider; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.core.userdetails.UserDetails; + +public class OlmisAuthenticationProvider extends DaoAuthenticationProvider { + + @Value("${maxUnsuccessfulAuthAttempts}") + private int maxUnsuccessfulAuthAttempts; + + @Value("${lockoutTime}") + private long lockoutTime; + + @Autowired + private UserRepository userRepository; + + @Autowired + private UnsuccessfulAuthenticationAttemptRepository attemptCounterRepository; + + @Override + protected void additionalAuthenticationChecks(UserDetails userDetails, + UsernamePasswordAuthenticationToken authentication) throws AuthenticationException { + User user = userRepository.findOneByUsernameIgnoreCase(userDetails.getUsername()); + UnsuccessfulAuthenticationAttempt counter = attemptCounterRepository + .findByUserId(user.getId()) + .orElse(new UnsuccessfulAuthenticationAttempt(user)); + boolean lockoutExpired = + Duration.between(counter.getLastUnsuccessfulAuthenticationAttemptDate(), + ZonedDateTime.now()).getSeconds() > lockoutTime; + + if (user.isLockedOut() && !lockoutExpired) { + throw new LockedException("Too many failed login attempts. " + + "You can't access this page right now. Please try again later."); + } else if (user.isLockedOut()) { + user.setLockedOut(false); + counter.resetCounter(); + attemptCounterRepository.save(counter); + userRepository.save(user); + } + + try { + super.additionalAuthenticationChecks(userDetails, authentication); + } catch (Exception ex) { + counter.incrementCounter(); + + if (counter.getAttemptCounter() >= maxUnsuccessfulAuthAttempts) { + user.setLockedOut(true); + userRepository.save(user); + } + attemptCounterRepository.save(counter); + throw ex; + } + + } +} diff --git a/src/main/java/org/openlmis/auth/security/SecurityConfiguration.java b/src/main/java/org/openlmis/auth/security/SecurityConfiguration.java index f8d9b39..e3ec93c 100644 --- a/src/main/java/org/openlmis/auth/security/SecurityConfiguration.java +++ b/src/main/java/org/openlmis/auth/security/SecurityConfiguration.java @@ -90,7 +90,7 @@ protected void configure(AuthenticationManagerBuilder auth) throws Exception { */ @Bean public AuthenticationProvider authenticator() { - DaoAuthenticationProvider provider = new DaoAuthenticationProvider(); + DaoAuthenticationProvider provider = new OlmisAuthenticationProvider(); provider.setUserDetailsService(userDetailsService); provider.setPasswordEncoder(new BCryptPasswordEncoder()); return provider; diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index e774233..eeb727d 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -58,3 +58,6 @@ spring.data.rest.maxPageSize=2147483647 cors.allowedOrigins=${CORS_ALLOWED_ORIGINS:} cors.allowedMethods=${CORS_ALLOWED_METHODS:} + +maxUnsuccessfulAuthAttempts=${MAX_UNSUCCESSFUL_AUTH_ATTEMPTS:0} +lockoutTime=${LOCKOUT_TIME:0} diff --git a/src/main/resources/db/migration/20240917224024116__add_unsuccessful_authentication_attempts_table.sql b/src/main/resources/db/migration/20240917224024116__add_unsuccessful_authentication_attempts_table.sql new file mode 100644 index 0000000..8c10915 --- /dev/null +++ b/src/main/resources/db/migration/20240917224024116__add_unsuccessful_authentication_attempts_table.sql @@ -0,0 +1,8 @@ +CREATE TABLE unsuccessful_authentication_attempts ( + id UUID PRIMARY KEY, + userId UUID NOT NULL UNIQUE, + lastUnsuccessfulAuthenticationAttemptDate TIMESTAMP WITH TIME ZONE NOT NULL, + attemptCounter INTEGER, + + CONSTRAINT fk_user FOREIGN KEY (userId) REFERENCES auth_users (id) ON DELETE CASCADE +); diff --git a/src/main/resources/db/migration/20240917224340535__add_locked_out_column_for_auth_users.sql b/src/main/resources/db/migration/20240917224340535__add_locked_out_column_for_auth_users.sql new file mode 100644 index 0000000..0bab0d0 --- /dev/null +++ b/src/main/resources/db/migration/20240917224340535__add_locked_out_column_for_auth_users.sql @@ -0,0 +1,2 @@ +ALTER TABLE auth_users +ADD COLUMN lockedOut BOOLEAN DEFAULT FALSE; diff --git a/src/main/resources/schemas/user.json b/src/main/resources/schemas/user.json index e21f264..fc932b9 100644 --- a/src/main/resources/schemas/user.json +++ b/src/main/resources/schemas/user.json @@ -22,6 +22,10 @@ "enabled": { "type": "boolean", "title": "enabled" + }, + "lockedOut": { + "type": "boolean", + "title": "lockedOut" } }, "required": [ diff --git a/src/test/java/org/openlmis/auth/domain/UnsuccessfulAuthenticationAttemptTest.java b/src/test/java/org/openlmis/auth/domain/UnsuccessfulAuthenticationAttemptTest.java new file mode 100644 index 0000000..49966d6 --- /dev/null +++ b/src/test/java/org/openlmis/auth/domain/UnsuccessfulAuthenticationAttemptTest.java @@ -0,0 +1,64 @@ +/* + * 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 static org.assertj.core.api.Assertions.assertThat; + +import java.time.ZonedDateTime; +import java.util.Random; +import org.junit.Before; +import org.junit.Test; +import org.openlmis.auth.UserDataBuilder; + +public class UnsuccessfulAuthenticationAttemptTest { + + private UnsuccessfulAuthenticationAttempt attempt; + + @Before + public void setUp() { + User user = new UserDataBuilder().build(); + attempt = new UnsuccessfulAuthenticationAttempt(user); + } + + @Test + public void shouldResetCounter() { + Random rand = new Random(); + int initialCounter = rand.nextInt(10); + attempt.setAttemptCounter(initialCounter); + ZonedDateTime initialAttemptDate = ZonedDateTime.now().minusHours(1L); + attempt.setLastUnsuccessfulAuthenticationAttemptDate(initialAttemptDate); + + attempt.resetCounter(); + + assertThat(attempt.getAttemptCounter()).isZero(); + assertThat(attempt.getLastUnsuccessfulAuthenticationAttemptDate()).isAfter(initialAttemptDate); + } + + @Test + public void shouldIncrementCounter() { + Random rand = new Random(); + int initialCounter = rand.nextInt(10); + attempt.setAttemptCounter(initialCounter); + ZonedDateTime initialAttemptDate = ZonedDateTime.now().minusHours(1L); + attempt.setLastUnsuccessfulAuthenticationAttemptDate(initialAttemptDate); + + attempt.incrementCounter(); + + assertThat(attempt.getAttemptCounter() - initialCounter).isOne(); + assertThat(attempt.getLastUnsuccessfulAuthenticationAttemptDate()).isAfter(initialAttemptDate); + } + +} diff --git a/src/test/java/org/openlmis/auth/domain/UserTest.java b/src/test/java/org/openlmis/auth/domain/UserTest.java index ec42b2f..fe5e95c 100644 --- a/src/test/java/org/openlmis/auth/domain/UserTest.java +++ b/src/test/java/org/openlmis/auth/domain/UserTest.java @@ -40,6 +40,7 @@ public void setUp() { importer.setId(UUID.randomUUID()); importer.setPassword("password"); importer.setEnabled(true); + importer.setLockedOut(false); } @Test