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

Reply via email to