Skip to content

Commit

Permalink
OIS-46: Added login lockout mechanism
Browse files Browse the repository at this point in the history
  • Loading branch information
sradziszewski committed Sep 19, 2024
1 parent 8126bd1 commit c5e572f
Show file tree
Hide file tree
Showing 13 changed files with 352 additions and 7 deletions.
Original file line number Diff line number Diff line change
@@ -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 [email protected].
*/

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<UnsuccessfulAuthenticationAttempt> {

@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<UnsuccessfulAuthenticationAttempt, UUID> 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;
}

}
Original file line number Diff line number Diff line change
@@ -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 [email protected].
*/

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());
}

}
15 changes: 9 additions & 6 deletions src/main/java/org/openlmis/auth/domain/User.java
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand All @@ -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)) {
Expand Down Expand Up @@ -109,6 +109,7 @@ public void export(Exporter exporter) {
exporter.setUsername(username);
exporter.setPassword(password);
exporter.setEnabled(enabled);
exporter.setLockedOut(lockedOut);

}

Expand All @@ -122,6 +123,7 @@ public interface Importer {

Boolean getEnabled();

boolean isLockedOut();
}

public interface Exporter {
Expand All @@ -134,6 +136,7 @@ public interface Exporter {

void setEnabled(Boolean enabled);

void setLockedOut(boolean lockedOut);
}

}
1 change: 1 addition & 0 deletions src/main/java/org/openlmis/auth/dto/UserDto.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Original file line number Diff line number Diff line change
@@ -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 [email protected].
*/

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<UnsuccessfulAuthenticationAttempt, UUID> {

Optional<UnsuccessfulAuthenticationAttempt> findByUserId(UUID userId);

}
Original file line number Diff line number Diff line change
@@ -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 [email protected].
*/

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;
}

}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
3 changes: 3 additions & 0 deletions src/main/resources/application.properties
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Original file line number Diff line number Diff line change
@@ -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
);
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
ALTER TABLE auth_users
ADD COLUMN lockedOut BOOLEAN DEFAULT FALSE;
4 changes: 4 additions & 0 deletions src/main/resources/schemas/user.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@
"enabled": {
"type": "boolean",
"title": "enabled"
},
"lockedOut": {
"type": "boolean",
"title": "lockedOut"
}
},
"required": [
Expand Down
Loading

0 comments on commit c5e572f

Please sign in to comment.