Skip to content
Open
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
Expand Up @@ -35,7 +35,9 @@
import org.openmrs.util.Security;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.openmrs.event.LoginAttemptEvent;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.orm.hibernate5.SessionFactoryUtils;
import org.springframework.orm.hibernate5.SessionHolder;
import org.springframework.transaction.annotation.Transactional;
Expand Down Expand Up @@ -72,6 +74,9 @@ public class HibernateContextDAO implements ContextDAO {

@Autowired
private FullTextSessionFactory fullTextSessionFactory;

@Autowired
private ApplicationEventPublisher eventPublisher;

private UserDAO userDao;

Expand Down Expand Up @@ -151,6 +156,9 @@ public User authenticate(String login, String password) throws ContextAuthentica
} else {
candidateUser.setUserProperty(OpenmrsConstants.USER_PROPERTY_LOCKOUT_TIMESTAMP, String.valueOf(System
.currentTimeMillis()));
// Publish LOGIN_FAILURE event — account is still within lockout period
eventPublisher.publishEvent(
new LoginAttemptEvent(this, login, candidateUser.getUserId(), false, "ACCOUNT_LOCKED", true));
throw new ContextAuthenticationException(
"Invalid number of connection attempts. Please try again later.");
}
Expand Down Expand Up @@ -179,6 +187,10 @@ public User authenticate(String login, String password) throws ContextAuthentica
}
setLastLoginTime(candidateUser);
saveUserProperties(candidateUser);

// Publish LOGIN_SUCCESS event for audit listeners
eventPublisher.publishEvent(
new LoginAttemptEvent(this, login, candidateUser.getUserId(), true, null, false));

// skip out of the method early (instead of throwing the exception)
// to indicate that this is the valid user
Expand Down Expand Up @@ -208,8 +220,14 @@ public User authenticate(String login, String password) throws ContextAuthentica
// set the user as locked out at this exact time
candidateUser.setUserProperty(OpenmrsConstants.USER_PROPERTY_LOCKOUT_TIMESTAMP, String.valueOf(System
.currentTimeMillis()));
// Publish LOGIN_FAILURE event wrong password triggered lockout (root cause: invalid credentials)
eventPublisher.publishEvent(
new LoginAttemptEvent(this, login, candidateUser.getUserId(), false, "INVALID_CREDENTIALS", true));
} else {
candidateUser.setUserProperty(OpenmrsConstants.USER_PROPERTY_LOGIN_ATTEMPTS, String.valueOf(attempts));
// Publish LOGIN_FAILURE event, invalid credentials, account not yet locked
eventPublisher.publishEvent(
new LoginAttemptEvent(this, login, candidateUser.getUserId(), false, "INVALID_CREDENTIALS", false));
}

saveUserProperties(candidateUser);
Expand Down
66 changes: 66 additions & 0 deletions api/src/main/java/org/openmrs/event/LoginAttemptEvent.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
/**
* This Source Code Form is subject to the terms of the Mozilla Public License,
* v. 2.0. If a copy of the MPL was not distributed with this file, You can
* obtain one at http://mozilla.org/MPL/2.0/. OpenMRS is also distributed under
* the terms of the Healthcare Disclaimer located at http://openmrs.org/license.
*
* Copyright (C) OpenMRS Inc. OpenMRS is a registered trademark and the OpenMRS
* graphic logo is a trademark of OpenMRS Inc.
*/
package org.openmrs.event;

import org.springframework.context.ApplicationEvent;

/**
* Published by {@link org.openmrs.api.db.hibernate.HibernateContextDAO} whenever a login
* attempt is made successful, failed, or resulting in account lockout.
*
* <p>Listeners (e.g. audit modules) can subscribe to this event to record security events
* without coupling to openmrs-core internals.
*
* @since 2.7.0
*/
public class LoginAttemptEvent extends ApplicationEvent {

private static final long serialVersionUID = 1L;

private final String username;

private final Integer userId;

private final boolean success;

private final String failureReason; // "INVALID_CREDENTIALS" | "ACCOUNT_LOCKED" | null on success

private final boolean accountLocked;

public LoginAttemptEvent(Object source, String username, Integer userId, boolean success, String failureReason,
boolean accountLocked) {
super(source);
this.username = username;
this.userId = userId;
this.success = success;
this.failureReason = failureReason;
this.accountLocked = accountLocked;
}

public String getUsername() {
return username;
}

public Integer getUserId() {
return userId;
}

public boolean isSuccess() {
return success;
}

public String getFailureReason() {
return failureReason;
}

public boolean isAccountLocked() {
return accountLocked;
}
}
65 changes: 65 additions & 0 deletions api/src/test/java/org/openmrs/api/db/ContextDAOTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,9 @@
import static org.junit.jupiter.api.Assertions.fail;

import javax.annotation.Resource;
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Properties;
import java.util.Set;

Expand All @@ -31,8 +33,10 @@
import org.openmrs.api.context.Context;
import org.openmrs.api.context.ContextAuthenticationException;
import org.openmrs.api.db.hibernate.HibernateContextDAO;
import org.openmrs.event.LoginAttemptEvent;
import org.openmrs.test.jupiter.BaseContextSensitiveTest;
import org.openmrs.util.PrivilegeConstants;
import org.springframework.context.ApplicationListener;
import org.springframework.stereotype.Component;

/**
Expand All @@ -50,6 +54,9 @@ public class ContextDAOTest extends BaseContextSensitiveTest {

@Resource(name = "testUserSessionListener")
TestUserSessionListener testUserSessionListener;

@Resource(name = "testLoginAttemptEventListener")
TestLoginAttemptEventListener testLoginAttemptEventListener;

/**
* Run this before each unit test in this class. The "@Before" method in
Expand Down Expand Up @@ -353,6 +360,48 @@ public void should_mergeDefaultRuntimeProperties() {
assertNotNull(properties.getProperty("hibernate.key"));
}

@Test
public void authenticate_shouldPublishLoginAttemptEventForSuccessfulLogin() {
testLoginAttemptEventListener.clear();

dao.authenticate("admin", "test");

assertThat(testLoginAttemptEventListener.events, contains("admin:1:true:null:false"));
}

@Test
public void authenticate_shouldPublishLoginAttemptEventForInvalidCredentials() {
testLoginAttemptEventListener.clear();

assertThrows(ContextAuthenticationException.class, () -> dao.authenticate("admin", "wrongPassword"));

assertThat(testLoginAttemptEventListener.events, contains("admin:1:false:INVALID_CREDENTIALS:false"));
}

@Test
public void authenticate_shouldPublishLoginAttemptEventWhenInvalidCredentialsLockAccount() {
testLoginAttemptEventListener.clear();

for (int x = 1; x <= 8; x++) {
assertThrows(ContextAuthenticationException.class, () -> dao.authenticate("admin", "wrongPassword"));
}

assertEquals("admin:1:false:INVALID_CREDENTIALS:true",
testLoginAttemptEventListener.events.get(testLoginAttemptEventListener.events.size() - 1));
}

@Test
public void authenticate_shouldPublishLoginAttemptEventWhenAccountIsLocked() {
for (int x = 1; x <= 8; x++) {
assertThrows(ContextAuthenticationException.class, () -> dao.authenticate("admin", "wrongPassword"));
}
testLoginAttemptEventListener.clear();

assertThrows(ContextAuthenticationException.class, () -> dao.authenticate("admin", "test"));

assertThat(testLoginAttemptEventListener.events, contains("admin:1:false:ACCOUNT_LOCKED:true"));
}

@Component("testUserSessionListener")
public static class TestUserSessionListener implements UserSessionListener {
public Set<String> logins = new LinkedHashSet<>();
Expand All @@ -375,6 +424,22 @@ public void clear() {
logouts.clear();
}
}

@Component("testLoginAttemptEventListener")
public static class TestLoginAttemptEventListener implements ApplicationListener<LoginAttemptEvent> {

public List<String> events = new ArrayList<>();

@Override
public void onApplicationEvent(LoginAttemptEvent event) {
events.add(event.getUsername() + ":" + event.getUserId() + ":" + event.isSuccess() + ":"
+ event.getFailureReason() + ":" + event.isAccountLocked());
}

public void clear() {
events.clear();
}
}

@Test
public void authenticate_shouldRightlyTriggerUserSessionListener_withSuccessfulLogin() {
Expand Down
Loading