Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

OIS-46: Added login lockout mechanism #16

Merged
merged 2 commits into from
Sep 30, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 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<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 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());
}

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

Optional<UnsuccessfulAuthenticationAttempt> findByUserId(UUID userId);

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
/*
* 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;
}

}

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