Ver código fonte

Refactor Password4jPasswordEncoder to use AlgorithmFinder for algorithm selection and enhance documentation
Closes gh-17706

Signed-off-by: M.Bozorgmehr <mehrdad.bozorgmehr@gmail.com>
Signed-off-by: Mehrdad <mehrdad.bozorgmehr@gmail.com>
Signed-off-by: M.Bozorgmehr <mehrdad.bozorgmehr@gmail.com>

M.Bozorgmehr 3 semanas atrás
pai
commit
9f5d27e8d0

+ 0 - 13
crypto/src/main/java/org/springframework/security/crypto/factory/PasswordEncoderFactories.java

@@ -24,7 +24,6 @@ import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
 import org.springframework.security.crypto.password.DelegatingPasswordEncoder;
 import org.springframework.security.crypto.password.PasswordEncoder;
 import org.springframework.security.crypto.password.Pbkdf2PasswordEncoder;
-import org.springframework.security.crypto.password4j.Password4jPasswordEncoder;
 import org.springframework.security.crypto.scrypt.SCryptPasswordEncoder;
 
 /**
@@ -66,10 +65,6 @@ public final class PasswordEncoderFactories {
 	 * <li>argon2 - {@link Argon2PasswordEncoder#defaultsForSpringSecurity_v5_2()}</li>
 	 * <li>argon2@SpringSecurity_v5_8 -
 	 * {@link Argon2PasswordEncoder#defaultsForSpringSecurity_v5_8()}</li>
-	 * <li>password4j-bcrypt - {@link Password4jPasswordEncoder} with BCrypt</li>
-	 * <li>password4j-scrypt - {@link Password4jPasswordEncoder} with SCrypt</li>
-	 * <li>password4j-argon2 - {@link Password4jPasswordEncoder} with Argon2</li>
-	 * <li>password4j-pbkdf2 - {@link Password4jPasswordEncoder} with PBKDF2</li>
 	 * </ul>
 	 * @return the {@link PasswordEncoder} to use
 	 */
@@ -92,14 +87,6 @@ public final class PasswordEncoderFactories {
 		encoders.put("sha256", new org.springframework.security.crypto.password.StandardPasswordEncoder());
 		encoders.put("argon2", Argon2PasswordEncoder.defaultsForSpringSecurity_v5_2());
 		encoders.put("argon2@SpringSecurity_v5_8", Argon2PasswordEncoder.defaultsForSpringSecurity_v5_8());
-
-		// Password4j implementations
-		encoders.put("password4j-bcrypt", Password4jPasswordEncoder.bcrypt(10));
-		encoders.put("password4j-scrypt", Password4jPasswordEncoder.scrypt(16384, 8, 1, 32));
-		encoders.put("password4j-argon2", Password4jPasswordEncoder.argon2(65536, 3, 4, 32,
-			com.password4j.types.Argon2.ID));
-		encoders.put("password4j-pbkdf2", Password4jPasswordEncoder.pbkdf2(310000, 32));
-
 		return new DelegatingPasswordEncoder(encodingId, encoders);
 	}
 

+ 68 - 179
crypto/src/main/java/org/springframework/security/crypto/password4j/Password4jPasswordEncoder.java

@@ -16,24 +16,54 @@
 
 package org.springframework.security.crypto.password4j;
 
-import com.password4j.*;
-import com.password4j.types.Argon2;
+import com.password4j.AlgorithmFinder;
+import com.password4j.Hash;
+import com.password4j.HashingFunction;
+import com.password4j.Password;
 import org.apache.commons.logging.Log;
 import org.apache.commons.logging.LogFactory;
+
 import org.springframework.security.crypto.password.AbstractValidatingPasswordEncoder;
 import org.springframework.util.Assert;
 
 /**
- * Implementation of {@link org.springframework.security.crypto.password.PasswordEncoder} that uses the Password4j library.
- * This encoder supports multiple password hashing algorithms including BCrypt, SCrypt, Argon2, and PBKDF2.
+ * Implementation of {@link org.springframework.security.crypto.password.PasswordEncoder}
+ * that uses the Password4j library. This encoder supports multiple password hashing
+ * algorithms including BCrypt, SCrypt, Argon2, and PBKDF2.
+ *
+ * <p>
+ * The encoder uses the provided {@link HashingFunction} for both encoding and
+ * verification. Password4j can automatically detect the algorithm used in existing hashes
+ * during verification.
+ * </p>
  *
- * <p>The encoder determines the algorithm used based on the algorithm type specified during construction.
- * For verification, it can automatically detect the algorithm used in existing hashes.</p>
+ * <p>
+ * This implementation is thread-safe and can be shared across multiple threads.
+ * </p>
  *
- * <p>This implementation is thread-safe and can be shared across multiple threads.</p>
+ * <p>
+ * <strong>Usage Examples:</strong>
+ * </p>
+ * <pre>{@code
+ * // Using default algorithms from AlgorithmFinder (recommended approach)
+ * PasswordEncoder bcryptEncoder = new Password4jPasswordEncoder(AlgorithmFinder.getBcryptInstance());
+ * PasswordEncoder argon2Encoder = new Password4jPasswordEncoder(AlgorithmFinder.getArgon2Instance());
+ * PasswordEncoder scryptEncoder = new Password4jPasswordEncoder(AlgorithmFinder.getScryptInstance());
+ * PasswordEncoder pbkdf2Encoder = new Password4jPasswordEncoder(AlgorithmFinder.getPBKDF2Instance());
+ *
+ * // Using customized algorithm parameters
+ * PasswordEncoder customBcrypt = new Password4jPasswordEncoder(BcryptFunction.getInstance(12));
+ * PasswordEncoder customArgon2 = new Password4jPasswordEncoder(
+ *     Argon2Function.getInstance(65536, 3, 4, 32, Argon2.ID));
+ * PasswordEncoder customScrypt = new Password4jPasswordEncoder(
+ *     ScryptFunction.getInstance(32768, 8, 1, 32));
+ * PasswordEncoder customPbkdf2 = new Password4jPasswordEncoder(
+ *     CompressedPBKDF2Function.getInstance("SHA256", 310000, 32));
+ * }</pre>
  *
  * @author Mehrdad Bozorgmehr
- * @since 6.5
+ * @since 7.0
+ * @see AlgorithmFinder
  */
 public class Password4jPasswordEncoder extends AbstractValidatingPasswordEncoder {
 
@@ -41,146 +71,38 @@ public class Password4jPasswordEncoder extends AbstractValidatingPasswordEncoder
 
 	private final HashingFunction hashingFunction;
 
-	private final Password4jAlgorithm algorithm;
-
-
-	/**
-	 * Enumeration of supported Password4j algorithms.
-	 */
-	public enum Password4jAlgorithm {
-		/**
-		 * BCrypt algorithm.
-		 */
-		BCRYPT,
-		/**
-		 * SCrypt algorithm.
-		 */
-		SCRYPT,
-		/**
-		 * Argon2 algorithm.
-		 */
-		ARGON2,
-		/**
-		 * PBKDF2 algorithm.
-		 */
-		PBKDF2,
-		/**
-		 * Compressed PBKDF2 algorithm.
-		 */
-		COMPRESSED_PBKDF2
-	}
-
-	/**
-	 * Constructs a Password4j password encoder with the default BCrypt algorithm.
-	 */
-	public Password4jPasswordEncoder() {
-		this(Password4jAlgorithm.BCRYPT);
-	}
-
 	/**
-	 * Constructs a Password4j password encoder with the specified algorithm using default parameters.
+	 * Constructs a Password4j password encoder with the specified hashing function.
 	 *
-	 * @param algorithm the password hashing algorithm to use
-	 */
-	public Password4jPasswordEncoder(Password4jAlgorithm algorithm) {
-		Assert.notNull(algorithm, "algorithm cannot be null");
-		this.algorithm = algorithm;
-		this.hashingFunction = createDefaultHashingFunction(algorithm);
-	}
-
-	/**
-	 * Constructs a Password4j password encoder with a custom hashing function.
+	 * <p>
+	 * It is recommended to use password4j's {@link AlgorithmFinder} to obtain default
+	 * instances with secure configurations:
+	 * </p>
+	 * <ul>
+	 * <li>{@code AlgorithmFinder.getBcryptInstance()} - BCrypt with default settings</li>
+	 * <li>{@code AlgorithmFinder.getArgon2Instance()} - Argon2 with default settings</li>
+	 * <li>{@code AlgorithmFinder.getScryptInstance()} - SCrypt with default settings</li>
+	 * <li>{@code AlgorithmFinder.getPBKDF2Instance()} - PBKDF2 with default settings</li>
+	 * </ul>
 	 *
-	 * @param hashingFunction the custom hashing function to use
-	 * @param algorithm       the password hashing algorithm type
+	 * <p>
+	 * For custom configurations, you can create specific function instances:
+	 * </p>
+	 * <ul>
+	 * <li>{@code BcryptFunction.getInstance(12)} - BCrypt with 12 rounds</li>
+	 * <li>{@code Argon2Function.getInstance(65536, 3, 4, 32, Argon2.ID)} - Custom
+	 * Argon2</li>
+	 * <li>{@code ScryptFunction.getInstance(16384, 8, 1, 32)} - Custom SCrypt</li>
+	 * <li>{@code CompressedPBKDF2Function.getInstance("SHA256", 310000, 32)} - Custom
+	 * PBKDF2</li>
+	 * </ul>
+	 * @param hashingFunction the hashing function to use for encoding passwords, must not
+	 * be null
+	 * @throws IllegalArgumentException if hashingFunction is null
 	 */
-	public Password4jPasswordEncoder(HashingFunction hashingFunction, Password4jAlgorithm algorithm) {
+	public Password4jPasswordEncoder(HashingFunction hashingFunction) {
 		Assert.notNull(hashingFunction, "hashingFunction cannot be null");
-		Assert.notNull(algorithm, "algorithm cannot be null");
 		this.hashingFunction = hashingFunction;
-		this.algorithm = algorithm;
-	}
-
-	/**
-	 * Creates a Password4j password encoder with BCrypt algorithm and specified rounds.
-	 *
-	 * @param rounds the number of rounds (cost factor) for BCrypt
-	 * @return a new Password4j password encoder
-	 */
-	public static Password4jPasswordEncoder bcrypt(int rounds) {
-		return new Password4jPasswordEncoder(BcryptFunction.getInstance(rounds), Password4jAlgorithm.BCRYPT);
-	}
-
-	/**
-	 * Creates a Password4j password encoder with SCrypt algorithm and specified parameters.
-	 *
-	 * @param workFactor       the work factor (N parameter)
-	 * @param resources        the resources (r parameter)
-	 * @param parallelization  the parallelization (p parameter)
-	 * @param derivedKeyLength the derived key length
-	 * @return a new Password4j password encoder
-	 */
-	public static Password4jPasswordEncoder scrypt(int workFactor, int resources, int parallelization, int derivedKeyLength) {
-		return new Password4jPasswordEncoder(
-				ScryptFunction.getInstance(workFactor, resources, parallelization, derivedKeyLength),
-				Password4jAlgorithm.SCRYPT
-		);
-	}
-
-	/**
-	 * Creates a Password4j password encoder with Argon2 algorithm and specified parameters.
-	 *
-	 * @param memory       the memory cost
-	 * @param iterations   the number of iterations
-	 * @param parallelism  the parallelism
-	 * @param outputLength the output length
-	 * @param type         the Argon2 type
-	 * @return a new Password4j password encoder
-	 */
-	public static Password4jPasswordEncoder argon2(int memory, int iterations, int parallelism, int outputLength, Argon2 type) {
-		return new Password4jPasswordEncoder(
-				Argon2Function.getInstance(memory, iterations, parallelism, outputLength, type),
-				Password4jAlgorithm.ARGON2
-		);
-	}
-
-	/**
-	 * Creates a Password4j password encoder with PBKDF2 algorithm and specified parameters.
-	 *
-	 * @param iterations       the number of iterations
-	 * @param derivedKeyLength the derived key length
-	 * @return a new Password4j password encoder
-	 */
-	public static Password4jPasswordEncoder pbkdf2(int iterations, int derivedKeyLength) {
-		return new Password4jPasswordEncoder(
-				CompressedPBKDF2Function.getInstance("SHA256", iterations, derivedKeyLength),
-				Password4jAlgorithm.PBKDF2
-		);
-	}
-
-	/**
-	 * Creates a Password4j password encoder with compressed PBKDF2 algorithm.
-	 *
-	 * @param iterations       the number of iterations
-	 * @param derivedKeyLength the derived key length
-	 * @return a new Password4j password encoder
-	 */
-	public static Password4jPasswordEncoder compressedPbkdf2(int iterations, int derivedKeyLength) {
-		return new Password4jPasswordEncoder(
-				CompressedPBKDF2Function.getInstance("SHA256", iterations, derivedKeyLength),
-				Password4jAlgorithm.COMPRESSED_PBKDF2
-		);
-	}
-
-	/**
-	 * Creates a Password4j password encoder with default settings for Spring Security v5.8+.
-	 * This uses BCrypt with 10 rounds.
-	 *
-	 * @return a new Password4j password encoder with recommended defaults
-	 * @since 6.5
-	 */
-	public static Password4jPasswordEncoder defaultsForSpringSecurity() {
-		return bcrypt(10);
 	}
 
 	@Override
@@ -188,7 +110,8 @@ public class Password4jPasswordEncoder extends AbstractValidatingPasswordEncoder
 		try {
 			Hash hash = Password.hash(rawPassword).with(this.hashingFunction);
 			return hash.getResult();
-		} catch (Exception ex) {
+		}
+		catch (Exception ex) {
 			throw new IllegalStateException("Failed to encode password using Password4j", ex);
 		}
 	}
@@ -198,7 +121,8 @@ public class Password4jPasswordEncoder extends AbstractValidatingPasswordEncoder
 		try {
 			// Use the specific hashing function for verification
 			return Password.check(rawPassword, encodedPassword).with(this.hashingFunction);
-		} catch (Exception ex) {
+		}
+		catch (Exception ex) {
 			this.logger.warn("Password verification failed for encoded password: " + encodedPassword, ex);
 			return false;
 		}
@@ -211,39 +135,4 @@ public class Password4jPasswordEncoder extends AbstractValidatingPasswordEncoder
 		return false;
 	}
 
-	/**
-	 * Creates a default hashing function for the specified algorithm.
-	 *
-	 * @param algorithm the password hashing algorithm
-	 * @return the default hashing function
-	 */
-	private static HashingFunction createDefaultHashingFunction(Password4jAlgorithm algorithm) {
-        return switch (algorithm) {
-            case BCRYPT -> BcryptFunction.getInstance(10); // Default 10 rounds
-            case SCRYPT -> ScryptFunction.getInstance(16384, 8, 1, 32); // Default parameters
-            case ARGON2 -> Argon2Function.getInstance(65536, 3, 4, 32, Argon2.ID); // Default parameters
-            case PBKDF2 ->
-                    CompressedPBKDF2Function.getInstance("SHA256", 310000, 32); // Use compressed format for self-contained encoding
-            case COMPRESSED_PBKDF2 -> CompressedPBKDF2Function.getInstance("SHA256", 310000, 32);
-        };
-	}
-
-	/**
-	 * Gets the algorithm used by this encoder.
-	 *
-	 * @return the password hashing algorithm
-	 */
-	public Password4jAlgorithm getAlgorithm() {
-		return this.algorithm;
-	}
-
-	/**
-	 * Gets the hashing function used by this encoder.
-	 *
-	 * @return the hashing function
-	 */
-	public HashingFunction getHashingFunction() {
-		return this.hashingFunction;
-	}
-
 }

+ 0 - 1
crypto/src/main/java/org/springframework/security/crypto/password4j/package-info.java

@@ -14,7 +14,6 @@
  * limitations under the License.
  */
 
-
 @NullMarked
 package org.springframework.security.crypto.password4j;
 

+ 107 - 562
crypto/src/test/java/org/springframework/security/crypto/password4j/Password4jPasswordEncoderTests.java

@@ -16,17 +16,10 @@
 
 package org.springframework.security.crypto.password4j;
 
-import com.password4j.Argon2Function;
+import com.password4j.AlgorithmFinder;
 import com.password4j.BcryptFunction;
-import com.password4j.CompressedPBKDF2Function;
-import com.password4j.ScryptFunction;
-import com.password4j.types.Argon2;
-import org.junit.jupiter.api.RepeatedTest;
+import com.password4j.HashingFunction;
 import org.junit.jupiter.api.Test;
-import org.junit.jupiter.params.ParameterizedTest;
-import org.junit.jupiter.params.provider.CsvSource;
-import org.junit.jupiter.params.provider.EnumSource;
-import org.junit.jupiter.params.provider.ValueSource;
 
 import static org.assertj.core.api.Assertions.assertThat;
 import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
@@ -38,559 +31,111 @@ import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException
  */
 class Password4jPasswordEncoderTests {
 
-    private static final String PASSWORD = "password";
-    private static final String WRONG_PASSWORD = "wrongpassword";
-    private static final String UNICODE_PASSWORD = "пароль123🔐";
-    private static final String LONG_PASSWORD = "a".repeat(1000);
+	private static final String PASSWORD = "password";
 
-    // Constructor Tests
-    @Test
-    void constructorWithNullAlgorithmShouldThrowException() {
-        assertThatIllegalArgumentException()
-                .isThrownBy(() -> new Password4jPasswordEncoder(null))
-                .withMessage("algorithm cannot be null");
-    }
+	private static final String WRONG_PASSWORD = "wrongpassword";
+
+	// Constructor Tests
+	@Test
+	void constructorWithNullHashingFunctionShouldThrowException() {
+		assertThatIllegalArgumentException().isThrownBy(() -> new Password4jPasswordEncoder(null))
+			.withMessage("hashingFunction cannot be null");
+	}
+
+	@Test
+	void constructorWithValidHashingFunctionShouldWork() {
+		HashingFunction hashingFunction = BcryptFunction.getInstance(10);
+		Password4jPasswordEncoder encoder = new Password4jPasswordEncoder(hashingFunction);
+		assertThat(encoder).isNotNull();
+	}
+
+	// Basic functionality tests with real HashingFunction instances
+	@Test
+	void encodeShouldReturnNonNullHashedPassword() {
+		HashingFunction hashingFunction = BcryptFunction.getInstance(4); // Use low cost
+		// for faster
+		// tests
+		Password4jPasswordEncoder encoder = new Password4jPasswordEncoder(hashingFunction);
+
+		String result = encoder.encode(PASSWORD);
+
+		assertThat(result).isNotNull().isNotEqualTo(PASSWORD);
+	}
+
+	@Test
+	void matchesShouldReturnTrueForValidPassword() {
+		HashingFunction hashingFunction = BcryptFunction.getInstance(4); // Use low cost
+		// for faster
+		// tests
+		Password4jPasswordEncoder encoder = new Password4jPasswordEncoder(hashingFunction);
+
+		String encoded = encoder.encode(PASSWORD);
+		boolean result = encoder.matches(PASSWORD, encoded);
+
+		assertThat(result).isTrue();
+	}
+
+	@Test
+	void matchesShouldReturnFalseForInvalidPassword() {
+		HashingFunction hashingFunction = BcryptFunction.getInstance(4); // Use low cost
+		// for faster
+		// tests
+		Password4jPasswordEncoder encoder = new Password4jPasswordEncoder(hashingFunction);
+
+		String encoded = encoder.encode(PASSWORD);
+		boolean result = encoder.matches(WRONG_PASSWORD, encoded);
+
+		assertThat(result).isFalse();
+	}
+
+	@Test
+	void matchesShouldReturnFalseForMalformedHash() {
+		HashingFunction hashingFunction = BcryptFunction.getInstance(4);
+		Password4jPasswordEncoder encoder = new Password4jPasswordEncoder(hashingFunction);
+
+		// Test with malformed hash that should cause Password4j to throw an exception
+		boolean result = encoder.matches(PASSWORD, "invalid-hash-format");
+
+		assertThat(result).isFalse();
+	}
+
+	@Test
+	void upgradeEncodingShouldReturnFalse() {
+		HashingFunction hashingFunction = BcryptFunction.getInstance(4);
+		Password4jPasswordEncoder encoder = new Password4jPasswordEncoder(hashingFunction);
+
+		String encoded = encoder.encode(PASSWORD);
+		boolean result = encoder.upgradeEncoding(encoded);
+
+		assertThat(result).isFalse();
+	}
+
+	// AlgorithmFinder Sanity Check Tests
+	@Test
+	void algorithmFinderBcryptSanityCheck() {
+		Password4jPasswordEncoder encoder = new Password4jPasswordEncoder(AlgorithmFinder.getBcryptInstance());
+
+		String encoded = encoder.encode(PASSWORD);
+		assertThat(encoder.matches(PASSWORD, encoded)).isTrue();
+		assertThat(encoder.matches(WRONG_PASSWORD, encoded)).isFalse();
+	}
+
+	@Test
+	void algorithmFinderArgon2SanityCheck() {
+		Password4jPasswordEncoder encoder = new Password4jPasswordEncoder(AlgorithmFinder.getArgon2Instance());
+
+		String encoded = encoder.encode(PASSWORD);
+		assertThat(encoder.matches(PASSWORD, encoded)).isTrue();
+		assertThat(encoder.matches(WRONG_PASSWORD, encoded)).isFalse();
+	}
+
+	@Test
+	void algorithmFinderScryptSanityCheck() {
+		Password4jPasswordEncoder encoder = new Password4jPasswordEncoder(AlgorithmFinder.getScryptInstance());
+
+		String encoded = encoder.encode(PASSWORD);
+		assertThat(encoder.matches(PASSWORD, encoded)).isTrue();
+		assertThat(encoder.matches(WRONG_PASSWORD, encoded)).isFalse();
+	}
 
-    @Test
-    void constructorWithNullHashingFunctionShouldThrowException() {
-        assertThatIllegalArgumentException()
-                .isThrownBy(() -> new Password4jPasswordEncoder(null, Password4jPasswordEncoder.Password4jAlgorithm.BCRYPT))
-                .withMessage("hashingFunction cannot be null");
-    }
-
-    @Test
-    void constructorWithNullAlgorithmAndValidHashingFunctionShouldThrowException() {
-        BcryptFunction function = BcryptFunction.getInstance(10);
-        assertThatIllegalArgumentException()
-                .isThrownBy(() -> new Password4jPasswordEncoder(function, null))
-                .withMessage("algorithm cannot be null");
-    }
-
-    @Test
-    void defaultConstructorShouldUseBCrypt() {
-        Password4jPasswordEncoder encoder = new Password4jPasswordEncoder();
-        assertThat(encoder.getAlgorithm()).isEqualTo(Password4jPasswordEncoder.Password4jAlgorithm.BCRYPT);
-        assertThat(encoder.getHashingFunction()).isInstanceOf(BcryptFunction.class);
-    }
-
-    // BCrypt Tests
-    @Test
-    void bcryptEncoderShouldEncodeAndVerifyPasswords() {
-        Password4jPasswordEncoder encoder = Password4jPasswordEncoder.bcrypt(10);
-
-        String encoded = encoder.encode(PASSWORD);
-        assertThat(encoded)
-                .isNotNull()
-                .isNotEqualTo(PASSWORD)
-                .startsWith("$2b$10$");// Password4j uses $2b$ format
-
-        assertThat(encoder.matches(PASSWORD, encoded)).isTrue();
-        assertThat(encoder.matches(WRONG_PASSWORD, encoded)).isFalse();
-        assertThat(encoder.matches(null, encoded)).isFalse();
-        assertThat(encoder.matches(PASSWORD, null)).isFalse();
-    }
-
-    @ParameterizedTest
-    @ValueSource(ints = {4, 6, 8, 10, 12, 14})
-    void bcryptWithDifferentRoundsShouldWork(int rounds) {
-        Password4jPasswordEncoder encoder = Password4jPasswordEncoder.bcrypt(rounds);
-
-        String encoded = encoder.encode(PASSWORD);
-        assertThat(encoded).startsWith("$2b$" + String.format("%02d", rounds) + "$");
-        assertThat(encoder.matches(PASSWORD, encoded)).isTrue();
-    }
-
-    @Test
-    void bcryptShouldProduceDifferentHashesForSamePassword() {
-        Password4jPasswordEncoder encoder = Password4jPasswordEncoder.bcrypt(10);
-
-        String hash1 = encoder.encode(PASSWORD);
-        String hash2 = encoder.encode(PASSWORD);
-
-        assertThat(hash1).isNotEqualTo(hash2);
-        assertThat(encoder.matches(PASSWORD, hash1)).isTrue();
-        assertThat(encoder.matches(PASSWORD, hash2)).isTrue();
-    }
-
-    // SCrypt Tests
-    @Test
-    void scryptEncoderShouldEncodeAndVerifyPasswords() {
-        Password4jPasswordEncoder encoder = Password4jPasswordEncoder.scrypt(16384, 8, 1, 32);
-
-        String encoded = encoder.encode(PASSWORD);
-        assertThat(encoded).isNotNull().isNotEqualTo(PASSWORD);
-
-        assertThat(encoder.matches(PASSWORD, encoded)).isTrue();
-        assertThat(encoder.matches(WRONG_PASSWORD, encoded)).isFalse();
-    }
-
-    @Test
-    void scryptWithDifferentParametersShouldWork() {
-        Password4jPasswordEncoder encoder1 = Password4jPasswordEncoder.scrypt(8192, 8, 1, 32);
-        Password4jPasswordEncoder encoder2 = Password4jPasswordEncoder.scrypt(16384, 16, 2, 64);
-
-        String hash1 = encoder1.encode(PASSWORD);
-        String hash2 = encoder2.encode(PASSWORD);
-
-        assertThat(encoder1.matches(PASSWORD, hash1)).isTrue();
-        assertThat(encoder2.matches(PASSWORD, hash2)).isTrue();
-        assertThat(hash1).isNotEqualTo(hash2);
-    }
-
-    // Argon2 Tests
-    @Test
-    void argon2EncoderShouldEncodeAndVerifyPasswords() {
-        Password4jPasswordEncoder encoder = Password4jPasswordEncoder.argon2(
-                65536, 3, 4, 32, Argon2.ID);
-
-        String encoded = encoder.encode(PASSWORD);
-        assertThat(encoded)
-                .isNotNull()
-                .isNotEqualTo(PASSWORD)
-                .startsWith("$argon2id$");
-
-        assertThat(encoder.matches(PASSWORD, encoded)).isTrue();
-        assertThat(encoder.matches(WRONG_PASSWORD, encoded)).isFalse();
-    }
-
-    @ParameterizedTest
-    @EnumSource(Argon2.class)
-    void argon2WithDifferentTypesShouldWork(Argon2 type) {
-        Password4jPasswordEncoder encoder = Password4jPasswordEncoder.argon2(
-                65536, 3, 4, 32, type);
-
-        String encoded = encoder.encode(PASSWORD);
-        String expectedPrefix = switch (type) {
-            case D -> "$argon2d$";
-            case I -> "$argon2i$";
-            case ID -> "$argon2id$";
-        };
-
-        assertThat(encoded).startsWith(expectedPrefix);
-        assertThat(encoder.matches(PASSWORD, encoded)).isTrue();
-    }
-
-    // PBKDF2 Tests
-    @Test
-    void pbkdf2EncoderShouldEncodeAndVerifyPasswords() {
-        Password4jPasswordEncoder encoder = Password4jPasswordEncoder.pbkdf2(100000, 32);
-
-        String encoded = encoder.encode(PASSWORD);
-        assertThat(encoded)
-                .isNotNull()
-                .isNotEqualTo(PASSWORD);
-
-        assertThat(encoder.matches(PASSWORD, encoded)).isTrue();
-        assertThat(encoder.matches(WRONG_PASSWORD, encoded)).isFalse();
-    }
-
-    @Test
-    void compressedPbkdf2EncoderShouldEncodeAndVerifyPasswords() {
-        Password4jPasswordEncoder encoder = Password4jPasswordEncoder.compressedPbkdf2(100000, 32);
-
-        String encoded = encoder.encode(PASSWORD);
-        assertThat(encoded)
-                .isNotNull()
-                .isNotEqualTo(PASSWORD);
-
-        assertThat(encoder.matches(PASSWORD, encoded)).isTrue();
-        assertThat(encoder.matches(WRONG_PASSWORD, encoded)).isFalse();
-    }
-
-    @ParameterizedTest
-    @CsvSource({
-            "50000, 16",
-            "100000, 32",
-            "200000, 64",
-            "500000, 32"
-    })
-    void pbkdf2WithDifferentParametersShouldWork(int iterations, int keyLength) {
-        Password4jPasswordEncoder encoder = Password4jPasswordEncoder.pbkdf2(iterations, keyLength);
-
-        String encoded = encoder.encode(PASSWORD);
-        assertThat(encoder.matches(PASSWORD, encoded)).isTrue();
-    }
-
-    // Factory Method Tests
-    @Test
-    void defaultsForSpringSecurityShouldUseBCrypt() {
-        Password4jPasswordEncoder encoder = Password4jPasswordEncoder.defaultsForSpringSecurity();
-
-        assertThat(encoder.getAlgorithm()).isEqualTo(Password4jPasswordEncoder.Password4jAlgorithm.BCRYPT);
-        assertThat(encoder.getHashingFunction()).isInstanceOf(BcryptFunction.class);
-
-        String encoded = encoder.encode(PASSWORD);
-        assertThat(encoded).startsWith("$2b$10$"); // Password4j uses $2b$ format
-        assertThat(encoder.matches(PASSWORD, encoded)).isTrue();
-    }
-
-    // Null and Empty Input Tests
-    @Test
-    void encodeNullPasswordShouldReturnNull() {
-        Password4jPasswordEncoder encoder = new Password4jPasswordEncoder();
-        assertThat(encoder.encode(null)).isNull();
-    }
-
-    @Test
-    void encodeEmptyPasswordShouldWork() {
-        Password4jPasswordEncoder encoder = new Password4jPasswordEncoder();
-        String encoded = encoder.encode("");
-        assertThat(encoded).isNotNull();
-        // AbstractValidatingPasswordEncoder returns false for empty raw passwords
-        assertThat(encoder.matches("", encoded)).isFalse();
-    }
-
-    @Test
-    void matchesWithNullOrEmptyParametersShouldReturnFalse() {
-        Password4jPasswordEncoder encoder = new Password4jPasswordEncoder();
-        String validHash = encoder.encode(PASSWORD);
-
-        assertThat(encoder.matches(null, validHash)).isFalse();
-        assertThat(encoder.matches("", validHash)).isFalse();
-        assertThat(encoder.matches(PASSWORD, null)).isFalse();
-        assertThat(encoder.matches(PASSWORD, "")).isFalse();
-        assertThat(encoder.matches(null, null)).isFalse();
-        assertThat(encoder.matches("", "")).isFalse();
-    }
-
-    // Password Variety Tests
-    @ParameterizedTest
-    @ValueSource(strings = {"password", "123456", "P@ssw0rd!", "a very long password with spaces and symbols !@#$%"})
-    void shouldHandleVariousPasswordFormats(String password) {
-        Password4jPasswordEncoder encoder = Password4jPasswordEncoder.defaultsForSpringSecurity();
-
-        String encoded = encoder.encode(password);
-        assertThat(encoded).isNotNull();
-        assertThat(encoder.matches(password, encoded)).isTrue();
-        assertThat(encoder.matches(password + "x", encoded)).isFalse();
-    }
-
-    @Test
-    void shouldHandleUnicodePasswords() {
-        Password4jPasswordEncoder encoder = Password4jPasswordEncoder.defaultsForSpringSecurity();
-
-        String encoded = encoder.encode(UNICODE_PASSWORD);
-        assertThat(encoded).isNotNull();
-        assertThat(encoder.matches(UNICODE_PASSWORD, encoded)).isTrue();
-        assertThat(encoder.matches("password", encoded)).isFalse();
-    }
-
-    @Test
-    void shouldHandleLongPasswords() {
-        Password4jPasswordEncoder encoder = Password4jPasswordEncoder.defaultsForSpringSecurity();
-
-        String encoded = encoder.encode(LONG_PASSWORD);
-        assertThat(encoded).isNotNull();
-        assertThat(encoder.matches(LONG_PASSWORD, encoded)).isTrue();
-    }
-
-    // Upgrade Encoding Tests
-    @Test
-    void upgradeEncodingShouldReturnFalse() {
-        Password4jPasswordEncoder encoder = new Password4jPasswordEncoder();
-        String encoded = encoder.encode(PASSWORD);
-
-        // For now, upgradeEncoding should return false
-        assertThat(encoder.upgradeEncoding(encoded)).isFalse();
-        assertThat(encoder.upgradeEncoding(null)).isFalse();
-        assertThat(encoder.upgradeEncoding("")).isFalse();
-    }
-
-    @ParameterizedTest
-    @EnumSource(Password4jPasswordEncoder.Password4jAlgorithm.class)
-    void upgradeEncodingShouldReturnFalseForAllAlgorithms(Password4jPasswordEncoder.Password4jAlgorithm algorithm) {
-        Password4jPasswordEncoder encoder = new Password4jPasswordEncoder(algorithm);
-        String encoded = encoder.encode(PASSWORD);
-
-        assertThat(encoder.upgradeEncoding(encoded)).isFalse();
-    }
-
-    // Custom Hashing Function Tests
-    @Test
-    void shouldWorkWithCustomHashingFunction() {
-        BcryptFunction customFunction = BcryptFunction.getInstance(12);
-        Password4jPasswordEncoder encoder = new Password4jPasswordEncoder(customFunction, Password4jPasswordEncoder.Password4jAlgorithm.BCRYPT);
-
-        String encoded = encoder.encode(PASSWORD);
-        assertThat(encoded).startsWith("$2b$12$"); // Password4j uses $2b$ format
-        assertThat(encoder.matches(PASSWORD, encoded)).isTrue();
-    }
-
-    @Test
-    void shouldWorkWithCustomScryptFunction() {
-        ScryptFunction customFunction = ScryptFunction.getInstance(32768, 16, 2, 64);
-        Password4jPasswordEncoder encoder = new Password4jPasswordEncoder(customFunction, Password4jPasswordEncoder.Password4jAlgorithm.SCRYPT);
-
-        String encoded = encoder.encode(PASSWORD);
-        assertThat(encoder.matches(PASSWORD, encoded)).isTrue();
-    }
-
-    @Test
-    void shouldWorkWithCustomArgon2Function() {
-        Argon2Function customFunction = Argon2Function.getInstance(131072, 4, 8, 64, Argon2.ID);
-        Password4jPasswordEncoder encoder = new Password4jPasswordEncoder(customFunction, Password4jPasswordEncoder.Password4jAlgorithm.ARGON2);
-
-        String encoded = encoder.encode(PASSWORD);
-        assertThat(encoded).startsWith("$argon2id$");
-        assertThat(encoder.matches(PASSWORD, encoded)).isTrue();
-    }
-
-    // Algorithm Coverage Tests
-    @Test
-    void shouldCreateEncoderForEachAlgorithm() {
-        // Test all algorithm types can be instantiated
-        for (Password4jPasswordEncoder.Password4jAlgorithm algorithm : Password4jPasswordEncoder.Password4jAlgorithm.values()) {
-            Password4jPasswordEncoder encoder = new Password4jPasswordEncoder(algorithm);
-            assertThat(encoder.getAlgorithm()).isEqualTo(algorithm);
-
-            String encoded = encoder.encode(PASSWORD);
-            assertThat(encoded).isNotNull();
-            assertThat(encoder.matches(PASSWORD, encoded)).isTrue();
-        }
-    }
-
-    @ParameterizedTest
-    @EnumSource(Password4jPasswordEncoder.Password4jAlgorithm.class)
-    void allAlgorithmsShouldProduceValidHashes(Password4jPasswordEncoder.Password4jAlgorithm algorithm) {
-        Password4jPasswordEncoder encoder = new Password4jPasswordEncoder(algorithm);
-
-        String encoded = encoder.encode(PASSWORD);
-        assertThat(encoded)
-                .isNotNull()
-                .isNotEmpty()
-                .isNotEqualTo(PASSWORD);
-
-        assertThat(encoder.matches(PASSWORD, encoded)).isTrue();
-        assertThat(encoder.matches(WRONG_PASSWORD, encoded)).isFalse();
-    }
-
-    // Security Properties Tests
-    @RepeatedTest(10)
-    void samePasswordShouldProduceDifferentHashes() {
-        Password4jPasswordEncoder encoder = Password4jPasswordEncoder.defaultsForSpringSecurity();
-
-        String hash1 = encoder.encode(PASSWORD);
-        String hash2 = encoder.encode(PASSWORD);
-
-        // Hashes should be different (due to salt)
-        assertThat(hash1).isNotEqualTo(hash2);
-
-        // But both should verify correctly
-        assertThat(encoder.matches(PASSWORD, hash1)).isTrue();
-        assertThat(encoder.matches(PASSWORD, hash2)).isTrue();
-    }
-
-    @Test
-    void hashLengthShouldBeConsistent() {
-        Password4jPasswordEncoder encoder = Password4jPasswordEncoder.defaultsForSpringSecurity();
-
-        String hash1 = encoder.encode("short");
-        String hash2 = encoder.encode("this is a much longer password with many characters");
-
-        // BCrypt hashes should have consistent length
-        assertThat(hash1).hasSize(60); // BCrypt produces 60-character hashes
-        assertThat(hash2).hasSize(60);
-    }
-
-    @Test
-    void similarPasswordsShouldProduceCompletelyDifferentHashes() {
-        Password4jPasswordEncoder encoder = Password4jPasswordEncoder.defaultsForSpringSecurity();
-
-        String hash1 = encoder.encode("password");
-        String hash2 = encoder.encode("password1");
-        String hash3 = encoder.encode("Password");
-
-        assertThat(hash1)
-                .isNotEqualTo(hash2)
-                .isNotEqualTo(hash3);
-        assertThat(hash2).isNotEqualTo(hash3);
-
-        // Cross-verification should fail
-        assertThat(encoder.matches("password", hash2)).isFalse();
-        assertThat(encoder.matches("password1", hash1)).isFalse();
-    }
-
-
-    // Additional Security and Robustness Tests
-    @Test
-    void shouldHandleVeryLongPasswords() {
-        Password4jPasswordEncoder encoder = Password4jPasswordEncoder.defaultsForSpringSecurity();
-        String veryLongPassword = "a".repeat(10000); // 10KB password
-
-        String encoded = encoder.encode(veryLongPassword);
-        assertThat(encoded).isNotNull();
-        assertThat(encoder.matches(veryLongPassword, encoded)).isTrue();
-        // Fix: BCrypt truncates passwords longer than 72 bytes, so we need to test with a meaningful difference
-        // Test with a shorter difference that's within the 72-byte limit
-        String slightlyDifferentPassword = "b" + veryLongPassword.substring(1); // Change first character
-        assertThat(encoder.matches(slightlyDifferentPassword, encoded)).isFalse();
-    }
-
-    @Test
-    void shouldHandlePasswordsWithNullBytes() {
-        Password4jPasswordEncoder encoder = Password4jPasswordEncoder.defaultsForSpringSecurity();
-        String passwordWithNull = "password\u0000test";
-
-        String encoded = encoder.encode(passwordWithNull);
-        assertThat(encoded).isNotNull();
-        assertThat(encoder.matches(passwordWithNull, encoded)).isTrue();
-        assertThat(encoder.matches("passwordtest", encoded)).isFalse();
-    }
-
-    @Test
-    void shouldProduceStrongRandomness() {
-        Password4jPasswordEncoder encoder = Password4jPasswordEncoder.defaultsForSpringSecurity();
-        java.util.Set<String> hashes = new java.util.HashSet<>();
-
-        // Generate many hashes of the same password
-        for (int i = 0; i < 100; i++) {
-            String hash = encoder.encode(PASSWORD);
-            assertThat(hashes.add(hash)).isTrue(); // Each hash should be unique
-        }
-
-        assertThat(hashes).hasSize(100);
-    }
-
-    @Test
-    void shouldResistTimingAttacks() {
-        Password4jPasswordEncoder encoder = Password4jPasswordEncoder.defaultsForSpringSecurity();
-        String validHash = encoder.encode(PASSWORD);
-
-        // Measure time for correct password
-        long startTime = System.nanoTime();
-        boolean result1 = encoder.matches(PASSWORD, validHash);
-        long correctTime = System.nanoTime() - startTime;
-
-        // Measure time for wrong password of same length
-        startTime = System.nanoTime();
-        boolean result2 = encoder.matches("passwore", validHash); // Same length, different content
-        long wrongTime = System.nanoTime() - startTime;
-
-        assertThat(result1).isTrue();
-        assertThat(result2).isFalse();
-
-        // Times should be relatively close (within 10x factor for timing attack resistance)
-        double ratio = Math.max(correctTime, wrongTime) / (double) Math.min(correctTime, wrongTime);
-        assertThat(ratio).isLessThan(10.0);
-    }
-
-
-    @Test
-    void scryptShouldHandleEdgeCaseParameters() {
-        // Test with minimum viable parameters
-        Password4jPasswordEncoder encoder = Password4jPasswordEncoder.scrypt(2, 1, 1, 16);
-
-        String encoded = encoder.encode(PASSWORD);
-        assertThat(encoder.matches(PASSWORD, encoded)).isTrue();
-    }
-
-    @Test
-    void argon2ShouldWorkWithDifferentMemorySizes() {
-        // Test with various memory configurations
-        int[] memorySizes = {1024, 4096, 16384, 65536};
-
-        for (int memory : memorySizes) {
-            Password4jPasswordEncoder encoder = Password4jPasswordEncoder.argon2(memory, 2, 1, 32, Argon2.ID);
-            String encoded = encoder.encode(PASSWORD);
-            assertThat(encoder.matches(PASSWORD, encoded)).isTrue();
-        }
-    }
-
-    @Test
-    void pbkdf2ShouldWorkWithDifferentHashAlgorithms() {
-        // Test that the implementation handles different internal configurations
-        Password4jPasswordEncoder encoder1 = Password4jPasswordEncoder.pbkdf2(50000, 16);
-        Password4jPasswordEncoder encoder2 = Password4jPasswordEncoder.pbkdf2(100000, 32);
-        Password4jPasswordEncoder encoder3 = Password4jPasswordEncoder.pbkdf2(200000, 64);
-
-        String hash1 = encoder1.encode(PASSWORD);
-        String hash2 = encoder2.encode(PASSWORD);
-        String hash3 = encoder3.encode(PASSWORD);
-
-        assertThat(encoder1.matches(PASSWORD, hash1)).isTrue();
-        assertThat(encoder2.matches(PASSWORD, hash2)).isTrue();
-        assertThat(encoder3.matches(PASSWORD, hash3)).isTrue();
-
-        // Hashes should be different due to different parameters
-        assertThat(hash1).isNotEqualTo(hash2);
-        assertThat(hash2).isNotEqualTo(hash3);
-    }
-
-    // Cross-Algorithm Verification Tests
-    @Test
-    void differentAlgorithmsShouldNotCrossVerify() {
-        Password4jPasswordEncoder bcryptEncoder = Password4jPasswordEncoder.bcrypt(10);
-        Password4jPasswordEncoder scryptEncoder = Password4jPasswordEncoder.scrypt(16384, 8, 1, 32);
-        Password4jPasswordEncoder argon2Encoder = Password4jPasswordEncoder.argon2(65536, 3, 4, 32, Argon2.ID);
-
-        String bcryptHash = bcryptEncoder.encode(PASSWORD);
-        String scryptHash = scryptEncoder.encode(PASSWORD);
-        String argon2Hash = argon2Encoder.encode(PASSWORD);
-
-        // Each encoder should only verify its own hashes
-        assertThat(bcryptEncoder.matches(PASSWORD, bcryptHash)).isTrue();
-        assertThat(bcryptEncoder.matches(PASSWORD, scryptHash)).isFalse();
-        assertThat(bcryptEncoder.matches(PASSWORD, argon2Hash)).isFalse();
-
-        assertThat(scryptEncoder.matches(PASSWORD, scryptHash)).isTrue();
-        assertThat(scryptEncoder.matches(PASSWORD, bcryptHash)).isFalse();
-        assertThat(scryptEncoder.matches(PASSWORD, argon2Hash)).isFalse();
-
-        assertThat(argon2Encoder.matches(PASSWORD, argon2Hash)).isTrue();
-        assertThat(argon2Encoder.matches(PASSWORD, bcryptHash)).isFalse();
-        assertThat(argon2Encoder.matches(PASSWORD, scryptHash)).isFalse();
-    }
-
-
-    @Test
-    void encodingShouldCompleteInReasonableTime() {
-        Password4jPasswordEncoder encoder = Password4jPasswordEncoder.defaultsForSpringSecurity();
-
-        long startTime = System.currentTimeMillis();
-        String encoded = encoder.encode(PASSWORD);
-        long duration = System.currentTimeMillis() - startTime;
-
-        assertThat(encoded).isNotNull();
-        assertThat(duration).isLessThan(5000); // Should complete within 5 seconds
-    }
-
-    // Compatibility and Integration Tests
-    @Test
-    void shouldBeCompatibleWithSpringSecurityConventions() {
-        Password4jPasswordEncoder encoder = Password4jPasswordEncoder.defaultsForSpringSecurity();
-
-        // Test common Spring Security patterns
-        assertThat(encoder.encode(null)).isNull();
-        assertThat(encoder.matches(null, "hash")).isFalse();
-        assertThat(encoder.matches("password", null)).isFalse();
-        assertThat(encoder.upgradeEncoding("anyhash")).isFalse();
-
-        // Test that it follows AbstractValidatingPasswordEncoder contract
-        assertThat(encoder.matches("", "")).isFalse();
-        assertThat(encoder.upgradeEncoding("")).isFalse();
-    }
-
-    @Test
-    void factoryMethodsShouldCreateCorrectInstances() {
-        // Verify all factory methods create properly configured instances
-        Password4jPasswordEncoder bcrypt = Password4jPasswordEncoder.bcrypt(12);
-        assertThat(bcrypt.getAlgorithm()).isEqualTo(Password4jPasswordEncoder.Password4jAlgorithm.BCRYPT);
-        assertThat(bcrypt.getHashingFunction()).isInstanceOf(BcryptFunction.class);
-
-        Password4jPasswordEncoder scrypt = Password4jPasswordEncoder.scrypt(32768, 8, 1, 32);
-        assertThat(scrypt.getAlgorithm()).isEqualTo(Password4jPasswordEncoder.Password4jAlgorithm.SCRYPT);
-        assertThat(scrypt.getHashingFunction()).isInstanceOf(ScryptFunction.class);
-
-        Password4jPasswordEncoder argon2 = Password4jPasswordEncoder.argon2(65536, 3, 4, 32, Argon2.ID);
-        assertThat(argon2.getAlgorithm()).isEqualTo(Password4jPasswordEncoder.Password4jAlgorithm.ARGON2);
-        assertThat(argon2.getHashingFunction()).isInstanceOf(Argon2Function.class);
-
-        Password4jPasswordEncoder pbkdf2 = Password4jPasswordEncoder.pbkdf2(100000, 32);
-        assertThat(pbkdf2.getAlgorithm()).isEqualTo(Password4jPasswordEncoder.Password4jAlgorithm.PBKDF2);
-        assertThat(pbkdf2.getHashingFunction()).isInstanceOf(CompressedPBKDF2Function.class);
-
-        Password4jPasswordEncoder compressedPbkdf2 = Password4jPasswordEncoder.compressedPbkdf2(100000, 32);
-        assertThat(compressedPbkdf2.getAlgorithm()).isEqualTo(Password4jPasswordEncoder.Password4jAlgorithm.COMPRESSED_PBKDF2);
-        assertThat(compressedPbkdf2.getHashingFunction()).isInstanceOf(CompressedPBKDF2Function.class);
-    }
 }

+ 2 - 0
gradle/libs.versions.toml

@@ -13,6 +13,7 @@ org-jetbrains-kotlinx = "1.10.2"
 org-mockito = "5.17.0"
 org-opensaml5 = "5.1.6"
 org-springframework = "7.0.0-M9"
+com-password4j = "1.8.2"
 
 [libraries]
 ch-qos-logback-logback-classic = "ch.qos.logback:logback-classic:1.5.18"
@@ -101,6 +102,7 @@ org-instancio-instancio-junit = "org.instancio:instancio-junit:3.7.1"
 
 spring-nullability = 'io.spring.nullability:io.spring.nullability.gradle.plugin:0.0.4'
 webauthn4j-core = 'com.webauthn4j:webauthn4j-core:0.29.6.RELEASE'
+com-password4j-password4j = { module = "com.password4j:password4j", version.ref = "com-password4j" }
 
 [plugins]