This is an automated email from the ASF dual-hosted git repository.
lprimak pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/shiro.git
The following commit(s) were added to refs/heads/main by this push:
new fb2af26cf Streamline authentication handling and credential matching
fb2af26cf is described below
commit fb2af26cf2c021260082f91c7ed8110b53e37085
Author: Benjamin Marwell <[email protected]>
AuthorDate: Sun Jan 18 13:53:53 2026 -0600
Streamline authentication handling and credential matching
---
.../credential/AllowAllCredentialsMatcher.java | 7 ++
.../shiro/authc/credential/CredentialsMatcher.java | 12 +++
.../shiro/authc/credential/PasswordMatcher.java | 7 +-
.../authc/credential/SimpleCredentialsMatcher.java | 34 ++++++
.../apache/shiro/realm/AuthenticatingRealm.java | 84 ++++++++++++---
.../shiro/realm/AuthenticatingRealmTest.groovy | 1 +
.../shiro/realm/AuthenticatingRealmJavaTest.java | 120 +++++++++++++++++++++
.../activedirectory/ActiveDirectoryRealmTest.java | 7 ++
8 files changed, 256 insertions(+), 16 deletions(-)
diff --git
a/core/src/main/java/org/apache/shiro/authc/credential/AllowAllCredentialsMatcher.java
b/core/src/main/java/org/apache/shiro/authc/credential/AllowAllCredentialsMatcher.java
index 998406857..d14dcd402 100644
---
a/core/src/main/java/org/apache/shiro/authc/credential/AllowAllCredentialsMatcher.java
+++
b/core/src/main/java/org/apache/shiro/authc/credential/AllowAllCredentialsMatcher.java
@@ -18,8 +18,10 @@
*/
package org.apache.shiro.authc.credential;
+import java.util.Optional;
import org.apache.shiro.authc.AuthenticationInfo;
import org.apache.shiro.authc.AuthenticationToken;
+import org.apache.shiro.authc.SimpleAuthenticationInfo;
/**
* A credentials matcher that always returns {@code true} when matching
credentials no matter what arguments
@@ -40,4 +42,9 @@ public class AllowAllCredentialsMatcher implements
CredentialsMatcher {
public boolean doCredentialsMatch(AuthenticationToken token,
AuthenticationInfo info) {
return true;
}
+
+ @Override
+ public Optional<AuthenticationInfo> createSimulatedCredentials() {
+ return Optional.of(new SimpleAuthenticationInfo("user", "password",
"realm"));
+ }
}
diff --git
a/core/src/main/java/org/apache/shiro/authc/credential/CredentialsMatcher.java
b/core/src/main/java/org/apache/shiro/authc/credential/CredentialsMatcher.java
index 4e6a525b2..a4f8cd436 100644
---
a/core/src/main/java/org/apache/shiro/authc/credential/CredentialsMatcher.java
+++
b/core/src/main/java/org/apache/shiro/authc/credential/CredentialsMatcher.java
@@ -18,6 +18,7 @@
*/
package org.apache.shiro.authc.credential;
+import java.util.Optional;
import org.apache.shiro.authc.AuthenticationInfo;
import org.apache.shiro.authc.AuthenticationToken;
@@ -48,4 +49,15 @@ public interface CredentialsMatcher {
*/
boolean doCredentialsMatch(AuthenticationToken token, AuthenticationInfo
info);
+ /**
+ * Create simulated credentials in case of a non-existent user trying to
log in.
+ *
+ * <p>Implementations must make sure to use an algorithm which is also
used by users, which
+ * roughly takes the same amount of time as checking a real user's
AuthenticationInfo.</p>
+ * <p>They must also make sure to return AuthenticationInfo which can
never be validated.</p>
+ * @return simulated AuthenticationInfo, created for non-existent users.
+ */
+ default Optional<AuthenticationInfo> createSimulatedCredentials() {
+ return Optional.empty();
+ }
}
diff --git
a/core/src/main/java/org/apache/shiro/authc/credential/PasswordMatcher.java
b/core/src/main/java/org/apache/shiro/authc/credential/PasswordMatcher.java
index 240fc0eac..01e10e357 100644
--- a/core/src/main/java/org/apache/shiro/authc/credential/PasswordMatcher.java
+++ b/core/src/main/java/org/apache/shiro/authc/credential/PasswordMatcher.java
@@ -18,6 +18,7 @@
*/
package org.apache.shiro.authc.credential;
+import java.util.Optional;
import org.apache.shiro.authc.AuthenticationInfo;
import org.apache.shiro.authc.AuthenticationToken;
import org.apache.shiro.crypto.hash.Hash;
@@ -33,7 +34,6 @@ import org.apache.shiro.lang.util.ByteSource;
* @since 1.2
*/
public class PasswordMatcher implements CredentialsMatcher {
-
private PasswordService passwordService;
public PasswordMatcher() {
@@ -58,6 +58,11 @@ public class PasswordMatcher implements CredentialsMatcher {
return service.passwordsMatch(submittedPassword, formatted);
}
+ @Override
+ public Optional<AuthenticationInfo> createSimulatedCredentials() {
+ return
SimpleCredentialsMatcher.makeSimulatedAuthenticationInfo(ensurePasswordService());
+ }
+
private PasswordService ensurePasswordService() {
PasswordService service = getPasswordService();
if (service == null) {
diff --git
a/core/src/main/java/org/apache/shiro/authc/credential/SimpleCredentialsMatcher.java
b/core/src/main/java/org/apache/shiro/authc/credential/SimpleCredentialsMatcher.java
index b3a09ab5b..0a3264b58 100644
---
a/core/src/main/java/org/apache/shiro/authc/credential/SimpleCredentialsMatcher.java
+++
b/core/src/main/java/org/apache/shiro/authc/credential/SimpleCredentialsMatcher.java
@@ -20,12 +20,17 @@ package org.apache.shiro.authc.credential;
import org.apache.shiro.authc.AuthenticationInfo;
import org.apache.shiro.authc.AuthenticationToken;
+import org.apache.shiro.authc.SimpleAuthenticationInfo;
+import org.apache.shiro.crypto.hash.format.Shiro2CryptFormat;
+import org.apache.shiro.lang.codec.Base64;
import org.apache.shiro.lang.codec.CodecSupport;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.security.MessageDigest;
+import java.security.SecureRandom;
import java.util.Arrays;
+import java.util.Optional;
/**
@@ -42,6 +47,8 @@ import java.util.Arrays;
public class SimpleCredentialsMatcher extends CodecSupport implements
CredentialsMatcher {
private static final Logger LOGGER =
LoggerFactory.getLogger(SimpleCredentialsMatcher.class);
+ /** Default number of bytes for a simulated password. */
+ private static final int DEFAULT_PASSWORD_BYTES = 18;
/**
* Returns the {@code token}'s credentials.
@@ -129,4 +136,31 @@ public class SimpleCredentialsMatcher extends CodecSupport
implements Credential
return equals(tokenCredentials, accountCredentials);
}
+ @Override
+ public Optional<AuthenticationInfo> createSimulatedCredentials() {
+ return SimpleCredentialsMatcher.makeSimulatedAuthenticationInfo(null);
+ }
+
+ /**
+ * default implementation which creates a simulated AuthenticationInfo
with a random password
+ * NOTE: the returned AuthenticationInfo must never validate successfully
+ * NOTE: make sure this is not called from performance-critical paths, as
it uses SecureRandom
+ * @return simulated AuthenticationInfo, created for non-existent users.
+ */
+ static Optional<AuthenticationInfo>
makeSimulatedAuthenticationInfo(PasswordService passwordService) {
+ final SecureRandom random = new SecureRandom();
+ final var bytes = new byte[DEFAULT_PASSWORD_BYTES];
+ random.nextBytes(bytes);
+ final var encode = Base64.encode(bytes);
+
+ Object parsedPassword;
+ if (passwordService == null) {
+ parsedPassword = encode;
+ } else {
+ parsedPassword = new
Shiro2CryptFormat().parse(passwordService.encryptPassword(encode));
+ }
+
+ return Optional.of(
+ new SimpleAuthenticationInfo("__principal__", parsedPassword, ""));
+ }
}
diff --git a/core/src/main/java/org/apache/shiro/realm/AuthenticatingRealm.java
b/core/src/main/java/org/apache/shiro/realm/AuthenticatingRealm.java
index 6d5085316..11cccbf5a 100644
--- a/core/src/main/java/org/apache/shiro/realm/AuthenticatingRealm.java
+++ b/core/src/main/java/org/apache/shiro/realm/AuthenticatingRealm.java
@@ -18,6 +18,8 @@
*/
package org.apache.shiro.realm;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicReference;
import org.apache.shiro.authc.AuthenticationException;
import org.apache.shiro.authc.AuthenticationInfo;
import org.apache.shiro.authc.AuthenticationToken;
@@ -28,13 +30,11 @@ import org.apache.shiro.authc.credential.CredentialsMatcher;
import org.apache.shiro.authc.credential.SimpleCredentialsMatcher;
import org.apache.shiro.cache.Cache;
import org.apache.shiro.cache.CacheManager;
-import org.apache.shiro.subject.PrincipalCollection;
import org.apache.shiro.lang.util.Initializable;
+import org.apache.shiro.subject.PrincipalCollection;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
-import java.util.concurrent.atomic.AtomicInteger;
-
/**
* A top-level abstract implementation of the <tt>Realm</tt> interface that
only implements authentication support
@@ -110,6 +110,7 @@ import java.util.concurrent.atomic.AtomicInteger;
*
* @since 0.2
*/
+@SuppressWarnings("checkstyle:MethodCount")
public abstract class AuthenticatingRealm extends CachingRealm implements
Initializable {
private static final Logger LOGGER =
LoggerFactory.getLogger(AuthenticatingRealm.class);
@@ -123,6 +124,12 @@ public abstract class AuthenticatingRealm extends
CachingRealm implements Initia
*/
private static final String DEFAULT_AUTHENTICATION_CACHE_SUFFIX =
".authenticationCache";
+ /**
+ * Simulated authentication info, should only be set once to avoid wasting
useless CPU cycles.
+ */
+ private final AtomicReference<AuthenticationInfo>
simulatedAuthenticationInfo =
+ new AtomicReference<>();
+
/**
* Credentials matcher used to determine if the provided credentials match
the credentials stored in the data store.
*/
@@ -578,9 +585,43 @@ public abstract class AuthenticatingRealm extends
CachingRealm implements Initia
if (info != null) {
assertCredentialsMatch(token, info);
} else {
- LOGGER.debug("No AuthenticationInfo found for submitted
AuthenticationToken [{}]. Returning null.", token);
+ simulateFailedLogin(token);
+ }
+
+ return info;
+ }
+
+ private void simulateFailedLogin(AuthenticationToken token) {
+ try {
+ AuthenticationInfo simulated = ensureSimulatedAuthenticationInfo();
+ if (simulated != null &&
assertCredentialsMatchWithoutException(token, simulated)) {
+ String msg = "Submitted credentials for token [" + token +
"] matched the simulated credentials. "
+ + "This indicates a misconfiguration of the realm's "
+ + "CredentialsMatcher or simulated credentials.
Please review your configuration.";
+ throw new IncorrectCredentialsException(msg);
+ }
+ } catch (AuthenticationException authenticationException) {
+ // should not happen as the auth info comes directly from the
credential service,
+ // but log to ensure implementations can find their flaw.
+ LOGGER.error(
+ "CredentialsMatcher [{}] threw exception on method
'doCredentialsMatch'",
+ getCredentialsMatcher(), authenticationException);
}
+ }
+ /**
+ * Make sure some AuthenticationInfo for simulated checks does exist. If
not, it will be generated.
+ * @return simulated AuthenticationInfo
+ */
+ AuthenticationInfo ensureSimulatedAuthenticationInfo() {
+ var info = simulatedAuthenticationInfo.get();
+ if (info == null) {
+ getCredentialsMatcher().createSimulatedCredentials()
+ .ifPresentOrElse(simulatedAuthenticationInfo::set, () ->
LOGGER.warn(
+ "CredentialsMatcher [{}] did not supply simulated
credentials. Please update the implementation.",
+ getCredentialsMatcher()));
+ return simulatedAuthenticationInfo.get();
+ }
return info;
}
@@ -593,17 +634,10 @@ public abstract class AuthenticatingRealm extends
CachingRealm implements Initia
* @throws AuthenticationException if the token's credentials do not match
the stored account credentials.
*/
protected void assertCredentialsMatch(AuthenticationToken token,
AuthenticationInfo info) throws AuthenticationException {
- CredentialsMatcher cm = getCredentialsMatcher();
- if (cm != null) {
- if (!cm.doCredentialsMatch(token, info)) {
- //not successful - throw an exception to indicate this:
- String msg = "Submitted credentials for token [" + token + "]
did not match the expected credentials.";
- throw new IncorrectCredentialsException(msg);
- }
- } else {
- throw new AuthenticationException("A CredentialsMatcher must be
configured in order to verify "
- + "credentials during authentication. If you do not wish
for credentials to be examined, you "
- + "can configure an " +
AllowAllCredentialsMatcher.class.getName() + " instance.");
+ if (!assertCredentialsMatchWithoutException(token, info)) {
+ //not successful - throw an exception to indicate this:
+ String msg = "Submitted credentials for token [" + token + "] did
not match the expected credentials.";
+ throw new IncorrectCredentialsException(msg);
}
}
@@ -710,4 +744,24 @@ public abstract class AuthenticatingRealm extends
CachingRealm implements Initia
*/
protected abstract AuthenticationInfo
doGetAuthenticationInfo(AuthenticationToken token) throws
AuthenticationException;
+ /**
+ * Asserts that the submitted {@code AuthenticationToken}'s credentials
match the stored account
+ * needed for simulated checks that do not need to throw exceptions.
+ *
+ * @param token the submitted authentication token
+ * @param info the AuthenticationInfo corresponding to the given {@code
token}
+ * @return true if the token's credentials match the stored account
credentials, false otherwise.
+ * @throws AuthenticationException only for configuration problems.
+ */
+ private boolean assertCredentialsMatchWithoutException(AuthenticationToken
token,
+ AuthenticationInfo
info) throws AuthenticationException {
+ CredentialsMatcher cm = getCredentialsMatcher();
+ if (cm != null) {
+ return cm.doCredentialsMatch(token, info);
+ } else {
+ throw new AuthenticationException("A CredentialsMatcher must be
configured in order to verify "
+ + "credentials during authentication. If you do not wish
for credentials to be examined, you "
+ + "can configure an " +
AllowAllCredentialsMatcher.class.getName() + " instance.");
+ }
+ }
}
diff --git
a/core/src/test/groovy/org/apache/shiro/realm/AuthenticatingRealmTest.groovy
b/core/src/test/groovy/org/apache/shiro/realm/AuthenticatingRealmTest.groovy
index 48c5638c1..114f70b80 100644
--- a/core/src/test/groovy/org/apache/shiro/realm/AuthenticatingRealmTest.groovy
+++ b/core/src/test/groovy/org/apache/shiro/realm/AuthenticatingRealmTest.groovy
@@ -141,6 +141,7 @@ class AuthenticatingRealmTest {
def token = createStrictMock(AuthenticationToken)
def info = createStrictMock(AuthenticationInfo)
+ expect(token.getCredentials()).andReturn(null).anyTimes()
replay token, info
diff --git
a/core/src/test/java/org/apache/shiro/realm/AuthenticatingRealmJavaTest.java
b/core/src/test/java/org/apache/shiro/realm/AuthenticatingRealmJavaTest.java
new file mode 100644
index 000000000..8828992d8
--- /dev/null
+++ b/core/src/test/java/org/apache/shiro/realm/AuthenticatingRealmJavaTest.java
@@ -0,0 +1,120 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.shiro.realm;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.times;
+
+import org.apache.shiro.authc.AuthenticationException;
+import org.apache.shiro.authc.AuthenticationInfo;
+import org.apache.shiro.authc.AuthenticationToken;
+import org.apache.shiro.authc.UsernamePasswordToken;
+import org.apache.shiro.authc.credential.CredentialsMatcher;
+import org.apache.shiro.authc.credential.DefaultPasswordService;
+import org.apache.shiro.authc.credential.PasswordMatcher;
+import org.apache.shiro.crypto.hash.DefaultHashService;
+import org.apache.shiro.crypto.hash.Hash;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.mockito.Mockito;
+
+public class AuthenticatingRealmJavaTest {
+
+ @Test
+ @DisplayName("should create Argon2 hash when user does not exist")
+ void authenticatingRealmShouldCreateArgonHashWhenUserDoesNotExist() {
+ // given
+ CredentialsMatcher matcher = new PasswordMatcher();
+ CredentialsMatcher passwordMatcher = Mockito.spy(matcher);
+ AuthenticationToken token = new UsernamePasswordToken("username",
"password".toCharArray());
+ NullAuthenticatingRealm realm = new NullAuthenticatingRealm();
+ realm.setCredentialsMatcher(passwordMatcher);
+ NullAuthenticatingRealm spiedRealm = Mockito.spy(realm);
+
+ // when
+ var info = spiedRealm.getAuthenticationInfo(token);
+
+ // then
+ assertThat(info).isNull();
+ Mockito.verify(passwordMatcher, times(1)).createSimulatedCredentials();
+
+ Object simulatedCredentials = spiedRealm.authInfo.getCredentials();
+ assertThat(simulatedCredentials).isInstanceOf(Hash.class);
+
assertThat(simulatedCredentials.getClass().getName()).contains("Argon");
+ }
+
+ @Test
+ @DisplayName("should create BCrypt hash when user does not exist")
+ void authenticatingRealmShouldCreateBcryptHashWhenUserDoesNotExist() {
+ // given
+ DefaultHashService bcraptHashService = new DefaultHashService();
+ bcraptHashService.setDefaultAlgorithmName("2y");
+ DefaultPasswordService defaultPasswordService = new
DefaultPasswordService();
+ defaultPasswordService.setHashService(bcraptHashService);
+ PasswordMatcher matcher = new PasswordMatcher();
+ matcher.setPasswordService(defaultPasswordService);
+
+ CredentialsMatcher passwordMatcher = Mockito.spy(matcher);
+ AuthenticationToken token = new UsernamePasswordToken("username",
"password".toCharArray());
+ NullAuthenticatingRealm realm = new NullAuthenticatingRealm();
+ realm.setCredentialsMatcher(passwordMatcher);
+ NullAuthenticatingRealm spiedRealm = Mockito.spy(realm);
+
+ // when
+ var info = spiedRealm.getAuthenticationInfo(token);
+
+ // then
+ assertThat(info).isNull();
+ Mockito.verify(passwordMatcher, times(1)).createSimulatedCredentials();
+
+ Object simulatedCredentials = spiedRealm.authInfo.getCredentials();
+ assertThat(simulatedCredentials).isInstanceOf(Hash.class);
+
assertThat(simulatedCredentials.getClass().getName()).contains("BCrypt");
+ }
+
+ /**
+ * For the test, it is important that this class returns {@code null} for
+ * {@link
AuthenticatingRealm#doGetAuthenticationInfo(AuthenticationToken)},
+ * so that simulatedAuthenticationInfo is being created.
+ */
+ static class NullAuthenticatingRealm extends AuthenticatingRealm {
+
+ /**
+ * captured created authenticationInfo
+ */
+ private AuthenticationInfo authInfo;
+
+ @Override
+ protected AuthenticationInfo
doGetAuthenticationInfo(AuthenticationToken token)
+ throws AuthenticationException {
+ return null;
+ }
+
+ /**
+ * Captures the created authenticationInfo for tests.
+ * @return see super method.
+ */
+ @Override
+ AuthenticationInfo ensureSimulatedAuthenticationInfo() {
+ final var authInfo1 = super.ensureSimulatedAuthenticationInfo();
+ this.authInfo = authInfo1;
+ return authInfo1;
+ }
+ }
+}
diff --git
a/core/src/test/java/org/apache/shiro/realm/activedirectory/ActiveDirectoryRealmTest.java
b/core/src/test/java/org/apache/shiro/realm/activedirectory/ActiveDirectoryRealmTest.java
index 1472c9c8c..26d08fe0e 100644
---
a/core/src/test/java/org/apache/shiro/realm/activedirectory/ActiveDirectoryRealmTest.java
+++
b/core/src/test/java/org/apache/shiro/realm/activedirectory/ActiveDirectoryRealmTest.java
@@ -18,12 +18,14 @@
*/
package org.apache.shiro.realm.activedirectory;
+import java.util.Optional;
import org.apache.shiro.SecurityUtils;
import org.apache.shiro.UnavailableSecurityManagerException;
import org.apache.shiro.authc.AuthenticationException;
import org.apache.shiro.authc.AuthenticationInfo;
import org.apache.shiro.authc.AuthenticationToken;
import org.apache.shiro.authc.SimpleAccount;
+import org.apache.shiro.authc.SimpleAuthenticationInfo;
import org.apache.shiro.authc.UsernamePasswordToken;
import org.apache.shiro.authc.credential.CredentialsMatcher;
import org.apache.shiro.authz.AuthorizationInfo;
@@ -196,6 +198,11 @@ public class ActiveDirectoryRealmTest {
public boolean doCredentialsMatch(AuthenticationToken object,
AuthenticationInfo object1) {
return true;
}
+
+ @Override
+ public Optional<AuthenticationInfo>
createSimulatedCredentials() {
+ return Optional.of(new SimpleAuthenticationInfo(USERNAME,
PASSWORD, "ad"));
+ }
};
setCredentialsMatcher(credentialsMatcher);