diff --git a/crypto/spring-security-crypto.gradle b/crypto/spring-security-crypto.gradle index 8370c1324c7..7fd438016db 100644 --- a/crypto/spring-security-crypto.gradle +++ b/crypto/spring-security-crypto.gradle @@ -8,6 +8,7 @@ dependencies { management platform(project(":spring-security-dependencies")) optional 'org.springframework:spring-core' optional 'org.bouncycastle:bcpkix-jdk18on' + optional libs.com.password4j.password4j testImplementation "org.assertj:assertj-core" testImplementation "org.junit.jupiter:junit-jupiter-api" diff --git a/crypto/src/main/java/org/springframework/security/crypto/password4j/Argon2Password4jPasswordEncoder.java b/crypto/src/main/java/org/springframework/security/crypto/password4j/Argon2Password4jPasswordEncoder.java new file mode 100644 index 00000000000..a0dc6a75135 --- /dev/null +++ b/crypto/src/main/java/org/springframework/security/crypto/password4j/Argon2Password4jPasswordEncoder.java @@ -0,0 +1,74 @@ +/* + * Copyright 2004-present the original author or authors. + * + * Licensed 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 + * + * https://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.springframework.security.crypto.password4j; + +import com.password4j.AlgorithmFinder; +import com.password4j.Argon2Function; + +/** + * Implementation of {@link org.springframework.security.crypto.password.PasswordEncoder} + * that uses the Password4j library with Argon2 hashing algorithm. + * + *
+ * Argon2 is the winner of the Password Hashing Competition (2015) and is recommended for + * new applications. It provides excellent resistance against GPU-based attacks and + * includes built-in salt generation. This implementation leverages Password4j's Argon2 + * support which properly includes the salt in the output hash. + *
+ * + *+ * This implementation is thread-safe and can be shared across multiple threads. + *
+ * + *+ * Usage Examples: + *
+ *{@code
+ * // Using default Argon2 settings (recommended)
+ * PasswordEncoder encoder = new Argon2Password4jPasswordEncoder();
+ *
+ * // Using custom Argon2 configuration
+ * PasswordEncoder customEncoder = new Argon2Password4jPasswordEncoder(
+ * Argon2Function.getInstance(65536, 3, 4, 32, Argon2.ID));
+ * }
+ *
+ * @author Mehrdad Bozorgmehr
+ * @since 7.0
+ * @see Argon2Function
+ * @see AlgorithmFinder#getArgon2Instance()
+ */
+public class Argon2Password4jPasswordEncoder extends Password4jPasswordEncoder {
+
+ /**
+ * Constructs an Argon2 password encoder using the default Argon2 configuration from
+ * Password4j's AlgorithmFinder.
+ */
+ public Argon2Password4jPasswordEncoder() {
+ super(AlgorithmFinder.getArgon2Instance());
+ }
+
+ /**
+ * Constructs an Argon2 password encoder with a custom Argon2 function.
+ * @param argon2Function the Argon2 function to use for encoding passwords, must not
+ * be null
+ * @throws IllegalArgumentException if argon2Function is null
+ */
+ public Argon2Password4jPasswordEncoder(Argon2Function argon2Function) {
+ super(argon2Function);
+ }
+
+}
diff --git a/crypto/src/main/java/org/springframework/security/crypto/password4j/BalloonHashingPassword4jPasswordEncoder.java b/crypto/src/main/java/org/springframework/security/crypto/password4j/BalloonHashingPassword4jPasswordEncoder.java
new file mode 100644
index 00000000000..54735f19b25
--- /dev/null
+++ b/crypto/src/main/java/org/springframework/security/crypto/password4j/BalloonHashingPassword4jPasswordEncoder.java
@@ -0,0 +1,159 @@
+/*
+ * Copyright 2004-present the original author or authors.
+ *
+ * Licensed 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
+ *
+ * https://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.springframework.security.crypto.password4j;
+
+import java.security.SecureRandom;
+import java.util.Base64;
+
+import com.password4j.AlgorithmFinder;
+import com.password4j.BalloonHashingFunction;
+import com.password4j.Hash;
+import com.password4j.Password;
+
+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 with Balloon hashing algorithm.
+ *
+ * + * Balloon hashing is a memory-hard password hashing algorithm designed to be resistant to + * both time-memory trade-off attacks and side-channel attacks. This implementation + * handles the salt management explicitly since Password4j's Balloon hashing + * implementation does not include the salt in the output hash. + *
+ * + *+ * The encoded password format is: {salt}:{hash} where both salt and hash are Base64 + * encoded. + *
+ * + *+ * This implementation is thread-safe and can be shared across multiple threads. + *
+ * + *+ * Usage Examples: + *
+ *{@code
+ * // Using default Balloon hashing settings (recommended)
+ * PasswordEncoder encoder = new BalloonHashingPassword4jPasswordEncoder();
+ *
+ * // Using custom Balloon hashing function
+ * PasswordEncoder customEncoder = new BalloonHashingPassword4jPasswordEncoder(
+ * BalloonHashingFunction.getInstance(1024, 3, 4, "SHA-256"));
+ * }
+ *
+ * @author Mehrdad Bozorgmehr
+ * @since 7.0
+ * @see BalloonHashingFunction
+ * @see AlgorithmFinder#getBalloonHashingInstance()
+ */
+public class BalloonHashingPassword4jPasswordEncoder extends AbstractValidatingPasswordEncoder {
+
+ private static final String DELIMITER = ":";
+
+ private static final int DEFAULT_SALT_LENGTH = 32;
+
+ private final BalloonHashingFunction balloonHashingFunction;
+
+ private final SecureRandom secureRandom;
+
+ private final int saltLength;
+
+ /**
+ * Constructs a Balloon hashing password encoder using the default Balloon hashing
+ * configuration from Password4j's AlgorithmFinder.
+ */
+ public BalloonHashingPassword4jPasswordEncoder() {
+ this(AlgorithmFinder.getBalloonHashingInstance());
+ }
+
+ /**
+ * Constructs a Balloon hashing password encoder with a custom Balloon hashing
+ * function.
+ * @param balloonHashingFunction the Balloon hashing function to use for encoding
+ * passwords, must not be null
+ * @throws IllegalArgumentException if balloonHashingFunction is null
+ */
+ public BalloonHashingPassword4jPasswordEncoder(BalloonHashingFunction balloonHashingFunction) {
+ this(balloonHashingFunction, DEFAULT_SALT_LENGTH);
+ }
+
+ /**
+ * Constructs a Balloon hashing password encoder with a custom Balloon hashing
+ * function and salt length.
+ * @param balloonHashingFunction the Balloon hashing function to use for encoding
+ * passwords, must not be null
+ * @param saltLength the length of the salt in bytes, must be positive
+ * @throws IllegalArgumentException if balloonHashingFunction is null or saltLength is
+ * not positive
+ */
+ public BalloonHashingPassword4jPasswordEncoder(BalloonHashingFunction balloonHashingFunction, int saltLength) {
+ Assert.notNull(balloonHashingFunction, "balloonHashingFunction cannot be null");
+ Assert.isTrue(saltLength > 0, "saltLength must be positive");
+ this.balloonHashingFunction = balloonHashingFunction;
+ this.saltLength = saltLength;
+ this.secureRandom = new SecureRandom();
+ }
+
+ @Override
+ protected String encodeNonNullPassword(String rawPassword) {
+ byte[] salt = new byte[this.saltLength];
+ this.secureRandom.nextBytes(salt);
+
+ Hash hash = Password.hash(rawPassword).addSalt(salt).with(this.balloonHashingFunction);
+ String encodedSalt = Base64.getEncoder().encodeToString(salt);
+ String encodedHash = hash.getResult();
+
+ return encodedSalt + DELIMITER + encodedHash;
+ }
+
+ @Override
+ protected boolean matchesNonNull(String rawPassword, String encodedPassword) {
+ if (!encodedPassword.contains(DELIMITER)) {
+ return false;
+ }
+
+ String[] parts = encodedPassword.split(DELIMITER, 2);
+ if (parts.length != 2) {
+ return false;
+ }
+
+ try {
+ byte[] salt = Base64.getDecoder().decode(parts[0]);
+ String expectedHash = parts[1];
+
+ Hash hash = Password.hash(rawPassword).addSalt(salt).with(this.balloonHashingFunction);
+ return expectedHash.equals(hash.getResult());
+ }
+ catch (IllegalArgumentException ex) {
+ // Invalid Base64 encoding
+ return false;
+ }
+ }
+
+ @Override
+ protected boolean upgradeEncodingNonNull(String encodedPassword) {
+ // For now, we'll return false to maintain existing behavior
+ // This could be enhanced in the future to check if the encoding parameters
+ // match the current configuration
+ return false;
+ }
+
+}
diff --git a/crypto/src/main/java/org/springframework/security/crypto/password4j/BcryptPassword4jPasswordEncoder.java b/crypto/src/main/java/org/springframework/security/crypto/password4j/BcryptPassword4jPasswordEncoder.java
new file mode 100644
index 00000000000..a1d8b8ae833
--- /dev/null
+++ b/crypto/src/main/java/org/springframework/security/crypto/password4j/BcryptPassword4jPasswordEncoder.java
@@ -0,0 +1,72 @@
+/*
+ * Copyright 2004-present the original author or authors.
+ *
+ * Licensed 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
+ *
+ * https://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.springframework.security.crypto.password4j;
+
+import com.password4j.AlgorithmFinder;
+import com.password4j.BcryptFunction;
+
+/**
+ * Implementation of {@link org.springframework.security.crypto.password.PasswordEncoder}
+ * that uses the Password4j library with BCrypt hashing algorithm.
+ *
+ * + * BCrypt is a well-established password hashing algorithm that includes built-in salt + * generation and is resistant to rainbow table attacks. This implementation leverages + * Password4j's BCrypt support which properly includes the salt in the output hash. + *
+ * + *+ * This implementation is thread-safe and can be shared across multiple threads. + *
+ * + *+ * Usage Examples: + *
+ *{@code
+ * // Using default BCrypt settings (recommended)
+ * PasswordEncoder encoder = new BcryptPassword4jPasswordEncoder();
+ *
+ * // Using custom round count
+ * PasswordEncoder customEncoder = new BcryptPassword4jPasswordEncoder(BcryptFunction.getInstance(12));
+ * }
+ *
+ * @author Mehrdad Bozorgmehr
+ * @since 7.0
+ * @see BcryptFunction
+ * @see AlgorithmFinder#getBcryptInstance()
+ */
+public class BcryptPassword4jPasswordEncoder extends Password4jPasswordEncoder {
+
+ /**
+ * Constructs a BCrypt password encoder using the default BCrypt configuration from
+ * Password4j's AlgorithmFinder.
+ */
+ public BcryptPassword4jPasswordEncoder() {
+ super(AlgorithmFinder.getBcryptInstance());
+ }
+
+ /**
+ * Constructs a BCrypt password encoder with a custom BCrypt function.
+ * @param bcryptFunction the BCrypt function to use for encoding passwords, must not
+ * be null
+ * @throws IllegalArgumentException if bcryptFunction is null
+ */
+ public BcryptPassword4jPasswordEncoder(BcryptFunction bcryptFunction) {
+ super(bcryptFunction);
+ }
+
+}
diff --git a/crypto/src/main/java/org/springframework/security/crypto/password4j/Password4jPasswordEncoder.java b/crypto/src/main/java/org/springframework/security/crypto/password4j/Password4jPasswordEncoder.java
new file mode 100644
index 00000000000..512da0e57ac
--- /dev/null
+++ b/crypto/src/main/java/org/springframework/security/crypto/password4j/Password4jPasswordEncoder.java
@@ -0,0 +1,78 @@
+/*
+ * Copyright 2004-present the original author or authors.
+ *
+ * Licensed 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
+ *
+ * https://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.springframework.security.crypto.password4j;
+
+import com.password4j.Hash;
+import com.password4j.HashingFunction;
+import com.password4j.Password;
+
+import org.springframework.security.crypto.password.AbstractValidatingPasswordEncoder;
+import org.springframework.util.Assert;
+
+/**
+ * Abstract base class for Password4j-based password encoders. This class provides the
+ * common functionality for password encoding and verification using the Password4j
+ * library.
+ *
+ * + * This class is package-private and should not be used directly. Instead, use the + * specific public subclasses that support verified hashing algorithms such as BCrypt, + * Argon2, and SCrypt implementations. + *
+ * + *+ * This implementation is thread-safe and can be shared across multiple threads. + *
+ * + * @author Mehrdad Bozorgmehr + * @since 7.0 + */ +abstract class Password4jPasswordEncoder extends AbstractValidatingPasswordEncoder { + + private final HashingFunction hashingFunction; + + /** + * Constructs a Password4j password encoder with the specified hashing function. This + * constructor is package-private and intended for use by subclasses only. + * @param hashingFunction the hashing function to use for encoding passwords, must not + * be null + * @throws IllegalArgumentException if hashingFunction is null + */ + Password4jPasswordEncoder(HashingFunction hashingFunction) { + Assert.notNull(hashingFunction, "hashingFunction cannot be null"); + this.hashingFunction = hashingFunction; + } + + @Override + protected String encodeNonNullPassword(String rawPassword) { + Hash hash = Password.hash(rawPassword).with(this.hashingFunction); + return hash.getResult(); + } + + @Override + protected boolean matchesNonNull(String rawPassword, String encodedPassword) { + return Password.check(rawPassword, encodedPassword).with(this.hashingFunction); + } + + @Override + protected boolean upgradeEncodingNonNull(String encodedPassword) { + // Password4j handles upgrade detection internally for most algorithms + // For now, we'll return false to maintain existing behavior + return false; + } + +} diff --git a/crypto/src/main/java/org/springframework/security/crypto/password4j/Pbkdf2Password4jPasswordEncoder.java b/crypto/src/main/java/org/springframework/security/crypto/password4j/Pbkdf2Password4jPasswordEncoder.java new file mode 100644 index 00000000000..65fbaa98e99 --- /dev/null +++ b/crypto/src/main/java/org/springframework/security/crypto/password4j/Pbkdf2Password4jPasswordEncoder.java @@ -0,0 +1,157 @@ +/* + * Copyright 2004-present the original author or authors. + * + * Licensed 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 + * + * https://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.springframework.security.crypto.password4j; + +import java.security.SecureRandom; +import java.util.Base64; + +import com.password4j.AlgorithmFinder; +import com.password4j.Hash; +import com.password4j.PBKDF2Function; +import com.password4j.Password; + +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 with PBKDF2 hashing algorithm. + * + *+ * PBKDF2 is a key derivation function designed to be computationally expensive to thwart + * dictionary and brute force attacks. This implementation handles the salt management + * explicitly since Password4j's PBKDF2 implementation does not include the salt in the + * output hash. + *
+ * + *+ * The encoded password format is: {salt}:{hash} where both salt and hash are Base64 + * encoded. + *
+ * + *+ * This implementation is thread-safe and can be shared across multiple threads. + *
+ * + *+ * Usage Examples: + *
+ *{@code
+ * // Using default PBKDF2 settings (recommended)
+ * PasswordEncoder encoder = new Pbkdf2Password4jPasswordEncoder();
+ *
+ * // Using custom PBKDF2 function
+ * PasswordEncoder customEncoder = new Pbkdf2Password4jPasswordEncoder(
+ * PBKDF2Function.getInstance(Algorithm.HMAC_SHA256, 100000, 256));
+ * }
+ *
+ * @author Mehrdad Bozorgmehr
+ * @since 7.0
+ * @see PBKDF2Function
+ * @see AlgorithmFinder#getPBKDF2Instance()
+ */
+public class Pbkdf2Password4jPasswordEncoder extends AbstractValidatingPasswordEncoder {
+
+ private static final String DELIMITER = ":";
+
+ private static final int DEFAULT_SALT_LENGTH = 32;
+
+ private final PBKDF2Function pbkdf2Function;
+
+ private final SecureRandom secureRandom;
+
+ private final int saltLength;
+
+ /**
+ * Constructs a PBKDF2 password encoder using the default PBKDF2 configuration from
+ * Password4j's AlgorithmFinder.
+ */
+ public Pbkdf2Password4jPasswordEncoder() {
+ this(AlgorithmFinder.getPBKDF2Instance());
+ }
+
+ /**
+ * Constructs a PBKDF2 password encoder with a custom PBKDF2 function.
+ * @param pbkdf2Function the PBKDF2 function to use for encoding passwords, must not
+ * be null
+ * @throws IllegalArgumentException if pbkdf2Function is null
+ */
+ public Pbkdf2Password4jPasswordEncoder(PBKDF2Function pbkdf2Function) {
+ this(pbkdf2Function, DEFAULT_SALT_LENGTH);
+ }
+
+ /**
+ * Constructs a PBKDF2 password encoder with a custom PBKDF2 function and salt length.
+ * @param pbkdf2Function the PBKDF2 function to use for encoding passwords, must not
+ * be null
+ * @param saltLength the length of the salt in bytes, must be positive
+ * @throws IllegalArgumentException if pbkdf2Function is null or saltLength is not
+ * positive
+ */
+ public Pbkdf2Password4jPasswordEncoder(PBKDF2Function pbkdf2Function, int saltLength) {
+ Assert.notNull(pbkdf2Function, "pbkdf2Function cannot be null");
+ Assert.isTrue(saltLength > 0, "saltLength must be positive");
+ this.pbkdf2Function = pbkdf2Function;
+ this.saltLength = saltLength;
+ this.secureRandom = new SecureRandom();
+ }
+
+ @Override
+ protected String encodeNonNullPassword(String rawPassword) {
+ byte[] salt = new byte[this.saltLength];
+ this.secureRandom.nextBytes(salt);
+
+ Hash hash = Password.hash(rawPassword).addSalt(salt).with(this.pbkdf2Function);
+ String encodedSalt = Base64.getEncoder().encodeToString(salt);
+ String encodedHash = hash.getResult();
+
+ return encodedSalt + DELIMITER + encodedHash;
+ }
+
+ @Override
+ protected boolean matchesNonNull(String rawPassword, String encodedPassword) {
+ if (!encodedPassword.contains(DELIMITER)) {
+ return false;
+ }
+
+ String[] parts = encodedPassword.split(DELIMITER, 2);
+ if (parts.length != 2) {
+ return false;
+ }
+
+ try {
+ byte[] salt = Base64.getDecoder().decode(parts[0]);
+ String expectedHash = parts[1];
+
+ Hash hash = Password.hash(rawPassword).addSalt(salt).with(this.pbkdf2Function);
+ return expectedHash.equals(hash.getResult());
+ }
+ catch (IllegalArgumentException ex) {
+ // Invalid Base64 encoding
+ return false;
+ }
+ }
+
+ @Override
+ protected boolean upgradeEncodingNonNull(String encodedPassword) {
+ // For now, we'll return false to maintain existing behavior
+ // This could be enhanced in the future to check if the encoding parameters
+ // match the current configuration
+ return false;
+ }
+
+}
diff --git a/crypto/src/main/java/org/springframework/security/crypto/password4j/ScryptPassword4jPasswordEncoder.java b/crypto/src/main/java/org/springframework/security/crypto/password4j/ScryptPassword4jPasswordEncoder.java
new file mode 100644
index 00000000000..c3e104bcd05
--- /dev/null
+++ b/crypto/src/main/java/org/springframework/security/crypto/password4j/ScryptPassword4jPasswordEncoder.java
@@ -0,0 +1,74 @@
+/*
+ * Copyright 2004-present the original author or authors.
+ *
+ * Licensed 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
+ *
+ * https://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.springframework.security.crypto.password4j;
+
+import com.password4j.AlgorithmFinder;
+import com.password4j.ScryptFunction;
+
+/**
+ * Implementation of {@link org.springframework.security.crypto.password.PasswordEncoder}
+ * that uses the Password4j library with SCrypt hashing algorithm.
+ *
+ * + * SCrypt is a memory-hard password hashing algorithm designed to be resistant to hardware + * brute-force attacks. It includes built-in salt generation and is particularly effective + * against ASIC and GPU-based attacks. This implementation leverages Password4j's SCrypt + * support which properly includes the salt in the output hash. + *
+ * + *+ * This implementation is thread-safe and can be shared across multiple threads. + *
+ * + *+ * Usage Examples: + *
+ *{@code
+ * // Using default SCrypt settings (recommended)
+ * PasswordEncoder encoder = new ScryptPassword4jPasswordEncoder();
+ *
+ * // Using custom SCrypt configuration
+ * PasswordEncoder customEncoder = new ScryptPassword4jPasswordEncoder(
+ * ScryptFunction.getInstance(32768, 8, 1, 32));
+ * }
+ *
+ * @author Mehrdad Bozorgmehr
+ * @since 7.0
+ * @see ScryptFunction
+ * @see AlgorithmFinder#getScryptInstance()
+ */
+public class ScryptPassword4jPasswordEncoder extends Password4jPasswordEncoder {
+
+ /**
+ * Constructs an SCrypt password encoder using the default SCrypt configuration from
+ * Password4j's AlgorithmFinder.
+ */
+ public ScryptPassword4jPasswordEncoder() {
+ super(AlgorithmFinder.getScryptInstance());
+ }
+
+ /**
+ * Constructs an SCrypt password encoder with a custom SCrypt function.
+ * @param scryptFunction the SCrypt function to use for encoding passwords, must not
+ * be null
+ * @throws IllegalArgumentException if scryptFunction is null
+ */
+ public ScryptPassword4jPasswordEncoder(ScryptFunction scryptFunction) {
+ super(scryptFunction);
+ }
+
+}
diff --git a/crypto/src/main/java/org/springframework/security/crypto/password4j/package-info.java b/crypto/src/main/java/org/springframework/security/crypto/password4j/package-info.java
new file mode 100644
index 00000000000..7310e80b8fd
--- /dev/null
+++ b/crypto/src/main/java/org/springframework/security/crypto/password4j/package-info.java
@@ -0,0 +1,20 @@
+/*
+ * Copyright 2004-present the original author or authors.
+ *
+ * Licensed 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
+ *
+ * https://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.
+ */
+
+@NullMarked
+package org.springframework.security.crypto.password4j;
+
+import org.jspecify.annotations.NullMarked;
diff --git a/crypto/src/test/java/org/springframework/security/crypto/password4j/Argon2Password4jPasswordEncoderTests.java b/crypto/src/test/java/org/springframework/security/crypto/password4j/Argon2Password4jPasswordEncoderTests.java
new file mode 100644
index 00000000000..37830371618
--- /dev/null
+++ b/crypto/src/test/java/org/springframework/security/crypto/password4j/Argon2Password4jPasswordEncoderTests.java
@@ -0,0 +1,245 @@
+/*
+ * Copyright 2004-present the original author or authors.
+ *
+ * Licensed 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
+ *
+ * https://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.springframework.security.crypto.password4j;
+
+import com.password4j.AlgorithmFinder;
+import com.password4j.Argon2Function;
+import com.password4j.types.Argon2;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.EnumSource;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
+
+/**
+ * Tests for {@link Argon2Password4jPasswordEncoder}.
+ *
+ * @author Mehrdad Bozorgmehr
+ */
+class Argon2Password4jPasswordEncoderTests {
+
+ private static final String PASSWORD = "password";
+
+ private static final String LONG_PASSWORD = "a".repeat(1000);
+
+ private static final String SPECIAL_CHARS_PASSWORD = "p@ssw0rd!#$%^&*()_+-=[]{}|;':\",./<>?";
+
+ private static final String UNICODE_PASSWORD = "пароль密码パスワード🔐";
+
+ @Test
+ void defaultConstructorShouldCreateWorkingEncoder() {
+ Argon2Password4jPasswordEncoder encoder = new Argon2Password4jPasswordEncoder();
+
+ String encoded = encoder.encode(PASSWORD);
+
+ assertThat(encoded).isNotNull();
+ assertThat(encoded).startsWith("$argon2"); // Argon2 hash format
+ assertThat(encoder.matches(PASSWORD, encoded)).isTrue();
+ }
+
+ @Test
+ void constructorWithNullArgon2FunctionShouldThrowException() {
+ assertThatIllegalArgumentException().isThrownBy(() -> new Argon2Password4jPasswordEncoder(null))
+ .withMessage("hashingFunction cannot be null");
+ }
+
+ @Test
+ void constructorWithCustomArgon2FunctionShouldWork() {
+ Argon2Function customFunction = Argon2Function.getInstance(4096, 3, 1, 32, Argon2.ID);
+ Argon2Password4jPasswordEncoder encoder = new Argon2Password4jPasswordEncoder(customFunction);
+
+ String encoded = encoder.encode(PASSWORD);
+
+ assertThat(encoded).isNotNull();
+ assertThat(encoded).startsWith("$argon2id");
+ assertThat(encoder.matches(PASSWORD, encoded)).isTrue();
+ }
+
+ @ParameterizedTest
+ @EnumSource(Argon2.class)
+ void encodingShouldWorkWithDifferentArgon2Types(Argon2 type) {
+ Argon2Function function = Argon2Function.getInstance(4096, 3, 1, 32, type);
+ Argon2Password4jPasswordEncoder encoder = new Argon2Password4jPasswordEncoder(function);
+
+ String encoded = encoder.encode(PASSWORD);
+
+ assertThat(encoded).isNotNull();
+ assertThat(encoded).startsWith("$argon2" + type.name().toLowerCase());
+ assertThat(encoder.matches(PASSWORD, encoded)).isTrue();
+ }
+
+ @Test
+ void encodingShouldGenerateDifferentHashesForSamePassword() {
+ Argon2Password4jPasswordEncoder encoder = new Argon2Password4jPasswordEncoder();
+
+ 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();
+ }
+
+ @Test
+ void shouldHandleLongPasswords() {
+ Argon2Password4jPasswordEncoder encoder = new Argon2Password4jPasswordEncoder();
+
+ String encoded = encoder.encode(LONG_PASSWORD);
+
+ assertThat(encoder.matches(LONG_PASSWORD, encoded)).isTrue();
+ assertThat(encoder.matches("wrong", encoded)).isFalse();
+ }
+
+ @Test
+ void shouldHandleSpecialCharacters() {
+ Argon2Password4jPasswordEncoder encoder = new Argon2Password4jPasswordEncoder();
+
+ String encoded = encoder.encode(SPECIAL_CHARS_PASSWORD);
+
+ assertThat(encoder.matches(SPECIAL_CHARS_PASSWORD, encoded)).isTrue();
+ assertThat(encoder.matches("wrong", encoded)).isFalse();
+ }
+
+ @Test
+ void shouldHandleUnicodeCharacters() {
+ Argon2Password4jPasswordEncoder encoder = new Argon2Password4jPasswordEncoder();
+
+ String encoded = encoder.encode(UNICODE_PASSWORD);
+
+ assertThat(encoder.matches(UNICODE_PASSWORD, encoded)).isTrue();
+ assertThat(encoder.matches("wrong", encoded)).isFalse();
+ }
+
+ @Test
+ void shouldRejectIncorrectPasswords() {
+ Argon2Password4jPasswordEncoder encoder = new Argon2Password4jPasswordEncoder();
+ String encoded = encoder.encode(PASSWORD);
+
+ assertThat(encoder.matches("wrongpassword", encoded)).isFalse();
+ assertThat(encoder.matches("PASSWORD", encoded)).isFalse(); // Case sensitive
+ assertThat(encoder.matches("password ", encoded)).isFalse(); // Trailing space
+ assertThat(encoder.matches(" password", encoded)).isFalse(); // Leading space
+ }
+
+ @Test
+ void matchesShouldReturnFalseForNullOrEmptyInputs() {
+ Argon2Password4jPasswordEncoder encoder = new Argon2Password4jPasswordEncoder();
+ String encoded = encoder.encode(PASSWORD);
+
+ assertThat(encoder.matches(null, encoded)).isFalse();
+ assertThat(encoder.matches("", encoded)).isFalse();
+ assertThat(encoder.matches(PASSWORD, null)).isFalse();
+ assertThat(encoder.matches(PASSWORD, "")).isFalse();
+ assertThat(encoder.matches(null, null)).isFalse();
+ assertThat(encoder.matches("", "")).isFalse();
+ }
+
+ @Test
+ void encodeNullShouldReturnNull() {
+ Argon2Password4jPasswordEncoder encoder = new Argon2Password4jPasswordEncoder();
+
+ assertThat(encoder.encode(null)).isNull();
+ }
+
+ @Test
+ void upgradeEncodingShouldReturnFalse() {
+ Argon2Password4jPasswordEncoder encoder = new Argon2Password4jPasswordEncoder();
+ String encoded = encoder.encode(PASSWORD);
+
+ assertThat(encoder.upgradeEncoding(encoded)).isFalse();
+ }
+
+ @Test
+ void shouldWorkWithAlgorithmFinderDefaults() {
+ Argon2Password4jPasswordEncoder encoder = new Argon2Password4jPasswordEncoder(
+ AlgorithmFinder.getArgon2Instance());
+
+ String encoded = encoder.encode(PASSWORD);
+
+ assertThat(encoded).isNotNull();
+ assertThat(encoder.matches(PASSWORD, encoded)).isTrue();
+ }
+
+ @Test
+ void shouldRejectMalformedHashes() {
+ Argon2Password4jPasswordEncoder encoder = new Argon2Password4jPasswordEncoder();
+
+ // For Argon2, Password4j may throw BadParametersException on malformed hashes.
+ // We treat either an exception or a false return as a successful rejection.
+ assertMalformedRejected(encoder, PASSWORD, "invalid_hash");
+ assertMalformedRejected(encoder, PASSWORD, "$argon2id$invalid");
+ assertMalformedRejected(encoder, PASSWORD, "");
+ }
+
+ private void assertMalformedRejected(Argon2Password4jPasswordEncoder encoder, String raw, String malformed) {
+ boolean rejected = false;
+ try {
+ rejected = !encoder.matches(raw, malformed);
+ }
+ catch (RuntimeException ex) {
+ // Accept exception as valid rejection path for malformed input
+ rejected = true;
+ }
+ assertThat(rejected).as("Malformed hash should not validate: " + malformed).isTrue();
+ }
+
+ @Test
+ void shouldHandleEmptyStringPassword() {
+ Argon2Password4jPasswordEncoder encoder = new Argon2Password4jPasswordEncoder();
+
+ String encoded = encoder.encode("");
+
+ assertThat(encoded).isNotNull();
+ boolean emptyStringMatches;
+ try {
+ emptyStringMatches = encoder.matches("", encoded);
+ }
+ catch (RuntimeException ex) {
+ emptyStringMatches = false; // treat exception as non-match but still
+ // acceptable behavior
+ }
+
+ if (emptyStringMatches) {
+ assertThat(encoder.matches("", encoded)).isTrue();
+ }
+ else {
+ assertThat(encoded).isNotEmpty();
+ }
+ assertThat(encoder.matches("notEmpty", encoded)).isFalse();
+ }
+
+ @Test
+ void shouldHandleCustomMemoryAndIterationParameters() {
+ // Test with different memory and iteration parameters
+ Argon2Function lowMemory = Argon2Function.getInstance(1024, 2, 1, 16, Argon2.ID);
+ Argon2Function highMemory = Argon2Function.getInstance(65536, 4, 2, 64, Argon2.ID);
+
+ Argon2Password4jPasswordEncoder lowEncoder = new Argon2Password4jPasswordEncoder(lowMemory);
+ Argon2Password4jPasswordEncoder highEncoder = new Argon2Password4jPasswordEncoder(highMemory);
+
+ String lowEncoded = lowEncoder.encode(PASSWORD);
+ String highEncoded = highEncoder.encode(PASSWORD);
+
+ assertThat(lowEncoder.matches(PASSWORD, lowEncoded)).isTrue();
+ assertThat(highEncoder.matches(PASSWORD, highEncoded)).isTrue();
+
+ // Each encoder should work with hashes generated by the same parameters
+ assertThat(lowEncoded).isNotEqualTo(highEncoded);
+ }
+
+}
diff --git a/crypto/src/test/java/org/springframework/security/crypto/password4j/BalloonHashingPassword4jPasswordEncoderTests.java b/crypto/src/test/java/org/springframework/security/crypto/password4j/BalloonHashingPassword4jPasswordEncoderTests.java
new file mode 100644
index 00000000000..97bd5e4af9f
--- /dev/null
+++ b/crypto/src/test/java/org/springframework/security/crypto/password4j/BalloonHashingPassword4jPasswordEncoderTests.java
@@ -0,0 +1,170 @@
+/*
+ * Copyright 2004-present the original author or authors.
+ *
+ * Licensed 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
+ *
+ * https://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.springframework.security.crypto.password4j;
+
+import com.password4j.AlgorithmFinder;
+import com.password4j.BalloonHashingFunction;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
+
+/**
+ * Tests for {@link BalloonHashingPassword4jPasswordEncoder}.
+ *
+ * @author Mehrdad Bozorgmehr
+ */
+class BalloonHashingPassword4jPasswordEncoderTests {
+
+ private static final String PASSWORD = "password";
+
+ private static final String DIFFERENT_PASSWORD = "differentpassword";
+
+ @Test
+ void constructorWithNullFunctionShouldThrowException() {
+ assertThatIllegalArgumentException().isThrownBy(() -> new BalloonHashingPassword4jPasswordEncoder(null))
+ .withMessage("balloonHashingFunction cannot be null");
+ }
+
+ @Test
+ void constructorWithInvalidSaltLengthShouldThrowException() {
+ BalloonHashingFunction function = AlgorithmFinder.getBalloonHashingInstance();
+ assertThatIllegalArgumentException().isThrownBy(() -> new BalloonHashingPassword4jPasswordEncoder(function, 0))
+ .withMessage("saltLength must be positive");
+ assertThatIllegalArgumentException().isThrownBy(() -> new BalloonHashingPassword4jPasswordEncoder(function, -1))
+ .withMessage("saltLength must be positive");
+ }
+
+ @Test
+ void defaultConstructorShouldWork() {
+ BalloonHashingPassword4jPasswordEncoder encoder = new BalloonHashingPassword4jPasswordEncoder();
+
+ String encoded = encoder.encode(PASSWORD);
+
+ assertThat(encoded).isNotNull().isNotEqualTo(PASSWORD);
+ assertThat(encoded).contains(":");
+ assertThat(encoder.matches(PASSWORD, encoded)).isTrue();
+ }
+
+ @Test
+ void customFunctionConstructorShouldWork() {
+ BalloonHashingFunction customFunction = BalloonHashingFunction.getInstance("SHA-256", 512, 2, 3);
+ BalloonHashingPassword4jPasswordEncoder encoder = new BalloonHashingPassword4jPasswordEncoder(customFunction);
+
+ String encoded = encoder.encode(PASSWORD);
+
+ assertThat(encoded).isNotNull().isNotEqualTo(PASSWORD);
+ assertThat(encoded).contains(":");
+ assertThat(encoder.matches(PASSWORD, encoded)).isTrue();
+ }
+
+ @Test
+ void customSaltLengthConstructorShouldWork() {
+ BalloonHashingFunction function = AlgorithmFinder.getBalloonHashingInstance();
+ BalloonHashingPassword4jPasswordEncoder encoder = new BalloonHashingPassword4jPasswordEncoder(function, 16);
+
+ String encoded = encoder.encode(PASSWORD);
+
+ assertThat(encoded).isNotNull().isNotEqualTo(PASSWORD);
+ assertThat(encoded).contains(":");
+ assertThat(encoder.matches(PASSWORD, encoded)).isTrue();
+ }
+
+ @Test
+ void encodeShouldIncludeSaltInOutput() {
+ BalloonHashingPassword4jPasswordEncoder encoder = new BalloonHashingPassword4jPasswordEncoder();
+
+ String encoded = encoder.encode(PASSWORD);
+
+ assertThat(encoded).contains(":");
+ String[] parts = encoded.split(":");
+ assertThat(parts).hasSize(2);
+ assertThat(parts[0]).isNotEmpty(); // salt part
+ assertThat(parts[1]).isNotEmpty(); // hash part
+ }
+
+ @Test
+ void matchesShouldReturnTrueForCorrectPassword() {
+ BalloonHashingPassword4jPasswordEncoder encoder = new BalloonHashingPassword4jPasswordEncoder();
+
+ String encoded = encoder.encode(PASSWORD);
+ boolean matches = encoder.matches(PASSWORD, encoded);
+
+ assertThat(matches).isTrue();
+ }
+
+ @Test
+ void matchesShouldReturnFalseForIncorrectPassword() {
+ BalloonHashingPassword4jPasswordEncoder encoder = new BalloonHashingPassword4jPasswordEncoder();
+
+ String encoded = encoder.encode(PASSWORD);
+ boolean matches = encoder.matches(DIFFERENT_PASSWORD, encoded);
+
+ assertThat(matches).isFalse();
+ }
+
+ @Test
+ void matchesShouldReturnFalseForMalformedEncodedPassword() {
+ BalloonHashingPassword4jPasswordEncoder encoder = new BalloonHashingPassword4jPasswordEncoder();
+
+ assertThat(encoder.matches(PASSWORD, "malformed")).isFalse();
+ assertThat(encoder.matches(PASSWORD, "no:delimiter:in:wrong:places")).isFalse();
+ assertThat(encoder.matches(PASSWORD, "invalid_base64!:hash")).isFalse();
+ }
+
+ @Test
+ void multipleEncodingsShouldProduceDifferentHashesButAllMatch() {
+ BalloonHashingPassword4jPasswordEncoder encoder = new BalloonHashingPassword4jPasswordEncoder();
+
+ String encoded1 = encoder.encode(PASSWORD);
+ String encoded2 = encoder.encode(PASSWORD);
+
+ assertThat(encoded1).isNotEqualTo(encoded2); // Different salts should produce
+ // different results
+ assertThat(encoder.matches(PASSWORD, encoded1)).isTrue();
+ assertThat(encoder.matches(PASSWORD, encoded2)).isTrue();
+ }
+
+ @Test
+ void upgradeEncodingShouldReturnFalse() {
+ BalloonHashingPassword4jPasswordEncoder encoder = new BalloonHashingPassword4jPasswordEncoder();
+
+ String encoded = encoder.encode(PASSWORD);
+ boolean shouldUpgrade = encoder.upgradeEncoding(encoded);
+
+ assertThat(shouldUpgrade).isFalse();
+ }
+
+ @Test
+ void encodeNullShouldReturnNull() {
+ BalloonHashingPassword4jPasswordEncoder encoder = new BalloonHashingPassword4jPasswordEncoder();
+
+ assertThat(encoder.encode(null)).isNull();
+ }
+
+ @Test
+ void matchesWithNullOrEmptyValuesShouldReturnFalse() {
+ BalloonHashingPassword4jPasswordEncoder encoder = new BalloonHashingPassword4jPasswordEncoder();
+ String encoded = encoder.encode(PASSWORD);
+
+ assertThat(encoder.matches(null, encoded)).isFalse();
+ assertThat(encoder.matches("", encoded)).isFalse();
+ assertThat(encoder.matches(PASSWORD, null)).isFalse();
+ assertThat(encoder.matches(PASSWORD, "")).isFalse();
+ }
+
+}
diff --git a/crypto/src/test/java/org/springframework/security/crypto/password4j/BcryptPassword4jPasswordEncoderTests.java b/crypto/src/test/java/org/springframework/security/crypto/password4j/BcryptPassword4jPasswordEncoderTests.java
new file mode 100644
index 00000000000..d790b206130
--- /dev/null
+++ b/crypto/src/test/java/org/springframework/security/crypto/password4j/BcryptPassword4jPasswordEncoderTests.java
@@ -0,0 +1,217 @@
+/*
+ * Copyright 2004-present the original author or authors.
+ *
+ * Licensed 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
+ *
+ * https://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.springframework.security.crypto.password4j;
+
+import com.password4j.AlgorithmFinder;
+import com.password4j.BcryptFunction;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
+
+/**
+ * Tests for {@link BcryptPassword4jPasswordEncoder}.
+ *
+ * @author Mehrdad Bozorgmehr
+ */
+class BcryptPassword4jPasswordEncoderTests {
+
+ private static final String PASSWORD = "password";
+
+ private static final String LONG_PASSWORD = "a".repeat(72); // BCrypt max length
+
+ private static final String VERY_LONG_PASSWORD = "a".repeat(100); // Beyond BCrypt max
+ // length
+
+ private static final String SPECIAL_CHARS_PASSWORD = "p@ssw0rd!#$%^&*()_+-=[]{}|;':\",./<>?";
+
+ private static final String UNICODE_PASSWORD = "пароль密码パスワード🔐";
+
+ @Test
+ void defaultConstructorShouldCreateWorkingEncoder() {
+ BcryptPassword4jPasswordEncoder encoder = new BcryptPassword4jPasswordEncoder();
+
+ String encoded = encoder.encode(PASSWORD);
+
+ assertThat(encoded).isNotNull().matches("^\\$2[aby]?\\$.*");
+ assertThat(encoder.matches(PASSWORD, encoded)).isTrue();
+ }
+
+ @Test
+ void constructorWithNullBcryptFunctionShouldThrowException() {
+ assertThatIllegalArgumentException().isThrownBy(() -> new BcryptPassword4jPasswordEncoder(null))
+ .withMessage("hashingFunction cannot be null");
+ }
+
+ @Test
+ void constructorWithCustomBcryptFunctionShouldWork() {
+ BcryptFunction customFunction = BcryptFunction.getInstance(6);
+ BcryptPassword4jPasswordEncoder encoder = new BcryptPassword4jPasswordEncoder(customFunction);
+
+ String encoded = encoder.encode(PASSWORD);
+
+ assertThat(encoded).isNotNull().contains("$06$"); // 6 rounds
+ assertThat(encoder.matches(PASSWORD, encoded)).isTrue();
+ }
+
+ @ParameterizedTest
+ @ValueSource(ints = { 4, 6, 8, 10, 12 })
+ void encodingShouldWorkWithDifferentRounds(int rounds) {
+ BcryptFunction function = BcryptFunction.getInstance(rounds);
+ BcryptPassword4jPasswordEncoder encoder = new BcryptPassword4jPasswordEncoder(function);
+
+ String encoded = encoder.encode(PASSWORD);
+
+ assertThat(encoded).isNotNull().contains(String.format("$%02d$", rounds));
+ assertThat(encoder.matches(PASSWORD, encoded)).isTrue();
+ }
+
+ @Test
+ void encodingShouldGenerateDifferentHashesForSamePassword() {
+ BcryptPassword4jPasswordEncoder encoder = new BcryptPassword4jPasswordEncoder();
+
+ 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();
+ }
+
+ @Test
+ void shouldHandleLongPasswords() {
+ BcryptPassword4jPasswordEncoder encoder = new BcryptPassword4jPasswordEncoder();
+
+ String encodedLong = encoder.encode(LONG_PASSWORD);
+ String encodedVeryLong = encoder.encode(VERY_LONG_PASSWORD);
+
+ assertThat(encoder.matches(LONG_PASSWORD, encodedLong)).isTrue();
+ assertThat(encoder.matches(VERY_LONG_PASSWORD, encodedVeryLong)).isTrue();
+ }
+
+ @Test
+ void shouldHandleSpecialCharacters() {
+ BcryptPassword4jPasswordEncoder encoder = new BcryptPassword4jPasswordEncoder();
+
+ String encoded = encoder.encode(SPECIAL_CHARS_PASSWORD);
+
+ assertThat(encoder.matches(SPECIAL_CHARS_PASSWORD, encoded)).isTrue();
+ assertThat(encoder.matches("wrong", encoded)).isFalse();
+ }
+
+ @Test
+ void shouldHandleUnicodeCharacters() {
+ BcryptPassword4jPasswordEncoder encoder = new BcryptPassword4jPasswordEncoder();
+
+ String encoded = encoder.encode(UNICODE_PASSWORD);
+
+ assertThat(encoder.matches(UNICODE_PASSWORD, encoded)).isTrue();
+ assertThat(encoder.matches("wrong", encoded)).isFalse();
+ }
+
+ @Test
+ void shouldRejectIncorrectPasswords() {
+ BcryptPassword4jPasswordEncoder encoder = new BcryptPassword4jPasswordEncoder();
+ String encoded = encoder.encode(PASSWORD);
+
+ assertThat(encoder.matches("wrongpassword", encoded)).isFalse();
+ assertThat(encoder.matches("PASSWORD", encoded)).isFalse(); // Case sensitive
+ assertThat(encoder.matches("password ", encoded)).isFalse(); // Trailing space
+ assertThat(encoder.matches(" password", encoded)).isFalse(); // Leading space
+ }
+
+ @Test
+ void matchesShouldReturnFalseForNullOrEmptyInputs() {
+ BcryptPassword4jPasswordEncoder encoder = new BcryptPassword4jPasswordEncoder();
+ String encoded = encoder.encode(PASSWORD);
+
+ assertThat(encoder.matches(null, encoded)).isFalse();
+ assertThat(encoder.matches("", encoded)).isFalse();
+ assertThat(encoder.matches(PASSWORD, null)).isFalse();
+ assertThat(encoder.matches(PASSWORD, "")).isFalse();
+ assertThat(encoder.matches(null, null)).isFalse();
+ assertThat(encoder.matches("", "")).isFalse();
+ }
+
+ @Test
+ void encodeNullShouldReturnNull() {
+ BcryptPassword4jPasswordEncoder encoder = new BcryptPassword4jPasswordEncoder();
+
+ assertThat(encoder.encode(null)).isNull();
+ }
+
+ @Test
+ void upgradeEncodingShouldReturnFalse() {
+ BcryptPassword4jPasswordEncoder encoder = new BcryptPassword4jPasswordEncoder();
+ String encoded = encoder.encode(PASSWORD);
+
+ assertThat(encoder.upgradeEncoding(encoded)).isFalse();
+ }
+
+ @Test
+ void shouldWorkWithAlgorithmFinderDefaults() {
+ BcryptPassword4jPasswordEncoder encoder = new BcryptPassword4jPasswordEncoder(
+ AlgorithmFinder.getBcryptInstance());
+
+ String encoded = encoder.encode(PASSWORD);
+
+ assertThat(encoded).isNotNull();
+ assertThat(encoder.matches(PASSWORD, encoded)).isTrue();
+ }
+
+ @Test
+ void shouldRejectMalformedHashes() {
+ BcryptPassword4jPasswordEncoder encoder = new BcryptPassword4jPasswordEncoder();
+ assertMalformedRejected(encoder, PASSWORD, "invalid_hash");
+ assertMalformedRejected(encoder, PASSWORD, "$2a$10$invalid");
+ assertMalformedRejected(encoder, PASSWORD, "");
+ }
+
+ private void assertMalformedRejected(BcryptPassword4jPasswordEncoder encoder, String raw, String malformed) {
+ boolean rejected;
+ try {
+ rejected = !encoder.matches(raw, malformed);
+ }
+ catch (RuntimeException ex) {
+ rejected = true; // exception is acceptable rejection
+ }
+ assertThat(rejected).as("Malformed hash should not validate: " + malformed).isTrue();
+ }
+
+ @Test
+ void shouldHandleEmptyStringPassword() {
+ BcryptPassword4jPasswordEncoder encoder = new BcryptPassword4jPasswordEncoder();
+ String encoded = encoder.encode("");
+ assertThat(encoded).isNotNull();
+ boolean emptyMatches;
+ try {
+ emptyMatches = encoder.matches("", encoded);
+ }
+ catch (RuntimeException ex) {
+ emptyMatches = false; // treat as non-match if library rejects empty raw
+ }
+ // Either behavior acceptable; if it matches, verify; if not, still ensure other
+ // mismatches remain false.
+ if (emptyMatches) {
+ assertThat(encoder.matches("", encoded)).isTrue();
+ }
+ assertThat(encoder.matches("notEmpty", encoded)).isFalse();
+ }
+
+}
diff --git a/crypto/src/test/java/org/springframework/security/crypto/password4j/Password4jPasswordEncoderTests.java b/crypto/src/test/java/org/springframework/security/crypto/password4j/Password4jPasswordEncoderTests.java
new file mode 100644
index 00000000000..f24bfbe42f9
--- /dev/null
+++ b/crypto/src/test/java/org/springframework/security/crypto/password4j/Password4jPasswordEncoderTests.java
@@ -0,0 +1,105 @@
+/*
+ * Copyright 2004-present the original author or authors.
+ *
+ * Licensed 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
+ *
+ * https://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.springframework.security.crypto.password4j;
+
+import com.password4j.BcryptFunction;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Base functionality tests for {@link Password4jPasswordEncoder} implementations. These
+ * tests verify the common behavior across all concrete password encoder subclasses.
+ *
+ * @author Mehrdad Bozorgmehr
+ */
+class Password4jPasswordEncoderTests {
+
+ private static final String PASSWORD = "password";
+
+ private static final String WRONG_PASSWORD = "wrongpassword";
+
+ // Test abstract class behavior through concrete implementation
+ @Test
+ void encodeShouldReturnNonNullHashedPassword() {
+ BcryptPassword4jPasswordEncoder encoder = new BcryptPassword4jPasswordEncoder(BcryptFunction.getInstance(4));
+
+ String result = encoder.encode(PASSWORD);
+
+ assertThat(result).isNotNull().isNotEqualTo(PASSWORD);
+ }
+
+ @Test
+ void matchesShouldReturnTrueForValidPassword() {
+ BcryptPassword4jPasswordEncoder encoder = new BcryptPassword4jPasswordEncoder(BcryptFunction.getInstance(4));
+
+ String encoded = encoder.encode(PASSWORD);
+ boolean result = encoder.matches(PASSWORD, encoded);
+
+ assertThat(result).isTrue();
+ }
+
+ @Test
+ void matchesShouldReturnFalseForInvalidPassword() {
+ BcryptPassword4jPasswordEncoder encoder = new BcryptPassword4jPasswordEncoder(BcryptFunction.getInstance(4));
+
+ String encoded = encoder.encode(PASSWORD);
+ boolean result = encoder.matches(WRONG_PASSWORD, encoded);
+
+ assertThat(result).isFalse();
+ }
+
+ @Test
+ void encodeNullPasswordShouldReturnNull() {
+ BcryptPassword4jPasswordEncoder encoder = new BcryptPassword4jPasswordEncoder(BcryptFunction.getInstance(4));
+
+ assertThat(encoder.encode(null)).isNull();
+ }
+
+ @Test
+ void multipleEncodesProduceDifferentHashesButAllMatch() {
+ BcryptPassword4jPasswordEncoder encoder = new BcryptPassword4jPasswordEncoder(BcryptFunction.getInstance(4));
+
+ String encoded1 = encoder.encode(PASSWORD);
+ String encoded2 = encoder.encode(PASSWORD);
+ // Bcrypt should produce different salted hashes for the same raw password
+ assertThat(encoded1).isNotEqualTo(encoded2);
+ assertThat(encoder.matches(PASSWORD, encoded1)).isTrue();
+ assertThat(encoder.matches(PASSWORD, encoded2)).isTrue();
+ }
+
+ @Test
+ void upgradeEncodingShouldReturnFalse() {
+ BcryptPassword4jPasswordEncoder encoder = new BcryptPassword4jPasswordEncoder(BcryptFunction.getInstance(4));
+
+ String encoded = encoder.encode(PASSWORD);
+ boolean result = encoder.upgradeEncoding(encoded);
+
+ assertThat(result).isFalse();
+ }
+
+ @Test
+ void matchesShouldReturnFalseWhenRawOrEncodedNullOrEmpty() {
+ BcryptPassword4jPasswordEncoder encoder = new BcryptPassword4jPasswordEncoder(BcryptFunction.getInstance(4));
+ String encoded = encoder.encode(PASSWORD);
+ assertThat(encoder.matches(null, encoded)).isFalse();
+ assertThat(encoder.matches("", encoded)).isFalse();
+ assertThat(encoder.matches(PASSWORD, null)).isFalse();
+ assertThat(encoder.matches(PASSWORD, "")).isFalse();
+ }
+
+}
diff --git a/crypto/src/test/java/org/springframework/security/crypto/password4j/PasswordCompatibilityTests.java b/crypto/src/test/java/org/springframework/security/crypto/password4j/PasswordCompatibilityTests.java
new file mode 100644
index 00000000000..d51e46e6e29
--- /dev/null
+++ b/crypto/src/test/java/org/springframework/security/crypto/password4j/PasswordCompatibilityTests.java
@@ -0,0 +1,153 @@
+/*
+ * Copyright 2004-present the original author or authors.
+ *
+ * Licensed 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
+ *
+ * https://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.springframework.security.crypto.password4j;
+
+import com.password4j.Argon2Function;
+import com.password4j.BcryptFunction;
+import com.password4j.ScryptFunction;
+import com.password4j.types.Argon2;
+import org.junit.jupiter.api.Test;
+
+import org.springframework.security.crypto.argon2.Argon2PasswordEncoder;
+import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
+import org.springframework.security.crypto.password.Pbkdf2PasswordEncoder;
+import org.springframework.security.crypto.scrypt.SCryptPasswordEncoder;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Tests compatibility between existing Spring Security password encoders and
+ * Password4j-based password encoders.
+ *
+ * @author Mehrdad Bozorgmehr
+ */
+class PasswordCompatibilityTests {
+
+ private static final String PASSWORD = "password";
+
+ // BCrypt Compatibility Tests
+ @Test
+ void bcryptEncodedWithSpringSecurityShouldMatchWithPassword4j() {
+ BCryptPasswordEncoder springEncoder = new BCryptPasswordEncoder(10);
+ BcryptPassword4jPasswordEncoder password4jEncoder = new BcryptPassword4jPasswordEncoder(
+ BcryptFunction.getInstance(10));
+
+ String encodedBySpring = springEncoder.encode(PASSWORD);
+ boolean matchedByPassword4j = password4jEncoder.matches(PASSWORD, encodedBySpring);
+
+ assertThat(matchedByPassword4j).isTrue();
+ }
+
+ @Test
+ void bcryptEncodedWithPassword4jShouldMatchWithSpringSecurity() {
+ BcryptPassword4jPasswordEncoder password4jEncoder = new BcryptPassword4jPasswordEncoder(
+ BcryptFunction.getInstance(10));
+ BCryptPasswordEncoder springEncoder = new BCryptPasswordEncoder(10);
+
+ String encodedByPassword4j = password4jEncoder.encode(PASSWORD);
+ boolean matchedBySpring = springEncoder.matches(PASSWORD, encodedByPassword4j);
+
+ assertThat(matchedBySpring).isTrue();
+ }
+
+ // Argon2 Compatibility Tests
+ @Test
+ void argon2EncodedWithSpringSecurityShouldMatchWithPassword4j() {
+ Argon2PasswordEncoder springEncoder = new Argon2PasswordEncoder(16, 32, 1, 4096, 3);
+ Argon2Password4jPasswordEncoder password4jEncoder = new Argon2Password4jPasswordEncoder(
+ Argon2Function.getInstance(4096, 3, 1, 32, Argon2.ID));
+
+ String encodedBySpring = springEncoder.encode(PASSWORD);
+ boolean matchedByPassword4j = password4jEncoder.matches(PASSWORD, encodedBySpring);
+
+ assertThat(matchedByPassword4j).isTrue();
+ }
+
+ @Test
+ void argon2EncodedWithPassword4jShouldMatchWithSpringSecurity() {
+ Argon2Password4jPasswordEncoder password4jEncoder = new Argon2Password4jPasswordEncoder(
+ Argon2Function.getInstance(4096, 3, 1, 32, Argon2.ID));
+ Argon2PasswordEncoder springEncoder = new Argon2PasswordEncoder(16, 32, 1, 4096, 3);
+
+ String encodedByPassword4j = password4jEncoder.encode(PASSWORD);
+ boolean matchedBySpring = springEncoder.matches(PASSWORD, encodedByPassword4j);
+
+ assertThat(matchedBySpring).isTrue();
+ }
+
+ // SCrypt Compatibility Tests
+ @Test
+ void scryptEncodedWithSpringSecurityShouldMatchWithPassword4j() {
+ SCryptPasswordEncoder springEncoder = new SCryptPasswordEncoder(16384, 8, 1, 32, 64);
+ ScryptPassword4jPasswordEncoder password4jEncoder = new ScryptPassword4jPasswordEncoder(
+ ScryptFunction.getInstance(16384, 8, 1, 32));
+
+ String encodedBySpring = springEncoder.encode(PASSWORD);
+ boolean matchedByPassword4j = password4jEncoder.matches(PASSWORD, encodedBySpring);
+
+ assertThat(matchedByPassword4j).isTrue();
+ }
+
+ @Test
+ void scryptEncodedWithPassword4jShouldMatchWithSpringSecurity() {
+ ScryptPassword4jPasswordEncoder password4jEncoder = new ScryptPassword4jPasswordEncoder(
+ ScryptFunction.getInstance(16384, 8, 1, 32));
+ SCryptPasswordEncoder springEncoder = new SCryptPasswordEncoder(16384, 8, 1, 32, 64);
+
+ String encodedByPassword4j = password4jEncoder.encode(PASSWORD);
+ boolean matchedBySpring = springEncoder.matches(PASSWORD, encodedByPassword4j);
+
+ assertThat(matchedBySpring).isTrue();
+ }
+
+ // PBKDF2 Compatibility Tests
+ @Test
+ void pbkdf2EncodedWithSpringSecurityCannotMatchWithPassword4j() {
+ // Note: Direct compatibility between Spring Security's Pbkdf2PasswordEncoder
+ // and Password4j's PBKDF2 implementation is not possible because they use
+ // different output formats. Spring Security uses hex encoding with a specific
+ // format,
+ // while our Password4jPasswordEncoder uses salt:hash format with Base64 encoding.
+ Pbkdf2PasswordEncoder springEncoder = Pbkdf2PasswordEncoder.defaultsForSpringSecurity_v5_8();
+ Pbkdf2Password4jPasswordEncoder password4jEncoder = new Pbkdf2Password4jPasswordEncoder();
+
+ String encodedBySpring = springEncoder.encode(PASSWORD);
+ String encodedByPassword4j = password4jEncoder.encode(PASSWORD);
+
+ // These should NOT match due to different formats
+ // Spring Security will throw an exception when trying to decode Password4j
+ // format,
+ // which should be treated as a non-match
+ boolean password4jCanMatchSpring = password4jEncoder.matches(PASSWORD, encodedBySpring);
+ boolean springCanMatchPassword4j;
+ try {
+ springCanMatchPassword4j = springEncoder.matches(PASSWORD, encodedByPassword4j);
+ }
+ catch (IllegalArgumentException ex) {
+ // Expected exception due to format incompatibility - treat as non-match
+ springCanMatchPassword4j = false;
+ }
+
+ assertThat(password4jCanMatchSpring).isFalse();
+ assertThat(springCanMatchPassword4j).isFalse();
+
+ // But each should match its own encoding
+ assertThat(springEncoder.matches(PASSWORD, encodedBySpring)).isTrue();
+ assertThat(password4jEncoder.matches(PASSWORD, encodedByPassword4j)).isTrue();
+ }
+
+}
diff --git a/crypto/src/test/java/org/springframework/security/crypto/password4j/Pbkdf2Password4jPasswordEncoderTests.java b/crypto/src/test/java/org/springframework/security/crypto/password4j/Pbkdf2Password4jPasswordEncoderTests.java
new file mode 100644
index 00000000000..040793ed557
--- /dev/null
+++ b/crypto/src/test/java/org/springframework/security/crypto/password4j/Pbkdf2Password4jPasswordEncoderTests.java
@@ -0,0 +1,167 @@
+/*
+ * Copyright 2004-present the original author or authors.
+ *
+ * Licensed 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
+ *
+ * https://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.springframework.security.crypto.password4j;
+
+import com.password4j.AlgorithmFinder;
+import com.password4j.PBKDF2Function;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
+
+/**
+ * Tests for {@link Pbkdf2Password4jPasswordEncoder}.
+ *
+ * @author Mehrdad Bozorgmehr
+ */
+class Pbkdf2Password4jPasswordEncoderTests {
+
+ private static final String PASSWORD = "password";
+
+ private static final String DIFFERENT_PASSWORD = "differentpassword";
+
+ @Test
+ void constructorWithNullFunctionShouldThrowException() {
+ assertThatIllegalArgumentException().isThrownBy(() -> new Pbkdf2Password4jPasswordEncoder(null))
+ .withMessage("pbkdf2Function cannot be null");
+ }
+
+ @Test
+ void constructorWithInvalidSaltLengthShouldThrowException() {
+ PBKDF2Function function = AlgorithmFinder.getPBKDF2Instance();
+ assertThatIllegalArgumentException().isThrownBy(() -> new Pbkdf2Password4jPasswordEncoder(function, 0))
+ .withMessage("saltLength must be positive");
+ assertThatIllegalArgumentException().isThrownBy(() -> new Pbkdf2Password4jPasswordEncoder(function, -1))
+ .withMessage("saltLength must be positive");
+ }
+
+ @Test
+ void defaultConstructorShouldWork() {
+ Pbkdf2Password4jPasswordEncoder encoder = new Pbkdf2Password4jPasswordEncoder();
+
+ String encoded = encoder.encode(PASSWORD);
+
+ assertThat(encoded).isNotNull().isNotEqualTo(PASSWORD).contains(":");
+ assertThat(encoder.matches(PASSWORD, encoded)).isTrue();
+ }
+
+ @Test
+ void customFunctionConstructorShouldWork() {
+ PBKDF2Function customFunction = AlgorithmFinder.getPBKDF2Instance();
+ Pbkdf2Password4jPasswordEncoder encoder = new Pbkdf2Password4jPasswordEncoder(customFunction);
+
+ String encoded = encoder.encode(PASSWORD);
+
+ assertThat(encoded).isNotNull().isNotEqualTo(PASSWORD).contains(":");
+ assertThat(encoder.matches(PASSWORD, encoded)).isTrue();
+ }
+
+ @Test
+ void customSaltLengthConstructorShouldWork() {
+ PBKDF2Function function = AlgorithmFinder.getPBKDF2Instance();
+ Pbkdf2Password4jPasswordEncoder encoder = new Pbkdf2Password4jPasswordEncoder(function, 16);
+
+ String encoded = encoder.encode(PASSWORD);
+
+ assertThat(encoded).isNotNull().isNotEqualTo(PASSWORD).contains(":");
+ assertThat(encoder.matches(PASSWORD, encoded)).isTrue();
+ }
+
+ @Test
+ void encodeShouldIncludeSaltInOutput() {
+ Pbkdf2Password4jPasswordEncoder encoder = new Pbkdf2Password4jPasswordEncoder();
+
+ String encoded = encoder.encode(PASSWORD);
+
+ assertThat(encoded).contains(":");
+ String[] parts = encoded.split(":");
+ assertThat(parts).hasSize(2);
+ assertThat(parts[0]).isNotEmpty(); // salt part
+ assertThat(parts[1]).isNotEmpty(); // hash part
+ }
+
+ @Test
+ void matchesShouldReturnTrueForCorrectPassword() {
+ Pbkdf2Password4jPasswordEncoder encoder = new Pbkdf2Password4jPasswordEncoder();
+
+ String encoded = encoder.encode(PASSWORD);
+ boolean matches = encoder.matches(PASSWORD, encoded);
+
+ assertThat(matches).isTrue();
+ }
+
+ @Test
+ void matchesShouldReturnFalseForIncorrectPassword() {
+ Pbkdf2Password4jPasswordEncoder encoder = new Pbkdf2Password4jPasswordEncoder();
+
+ String encoded = encoder.encode(PASSWORD);
+ boolean matches = encoder.matches(DIFFERENT_PASSWORD, encoded);
+
+ assertThat(matches).isFalse();
+ }
+
+ @Test
+ void matchesShouldReturnFalseForMalformedEncodedPassword() {
+ Pbkdf2Password4jPasswordEncoder encoder = new Pbkdf2Password4jPasswordEncoder();
+
+ assertThat(encoder.matches(PASSWORD, "malformed")).isFalse();
+ assertThat(encoder.matches(PASSWORD, "no:delimiter:in:wrong:places")).isFalse();
+ assertThat(encoder.matches(PASSWORD, "invalid_base64!:hash")).isFalse();
+ }
+
+ @Test
+ void multipleEncodingsShouldProduceDifferentHashesButAllMatch() {
+ Pbkdf2Password4jPasswordEncoder encoder = new Pbkdf2Password4jPasswordEncoder();
+
+ String encoded1 = encoder.encode(PASSWORD);
+ String encoded2 = encoder.encode(PASSWORD);
+
+ assertThat(encoded1).isNotEqualTo(encoded2); // Different salts should produce
+ // different results
+ assertThat(encoder.matches(PASSWORD, encoded1)).isTrue();
+ assertThat(encoder.matches(PASSWORD, encoded2)).isTrue();
+ }
+
+ @Test
+ void upgradeEncodingShouldReturnFalse() {
+ Pbkdf2Password4jPasswordEncoder encoder = new Pbkdf2Password4jPasswordEncoder();
+
+ String encoded = encoder.encode(PASSWORD);
+ boolean shouldUpgrade = encoder.upgradeEncoding(encoded);
+
+ assertThat(shouldUpgrade).isFalse();
+ }
+
+ @Test
+ void encodeNullShouldReturnNull() {
+ Pbkdf2Password4jPasswordEncoder encoder = new Pbkdf2Password4jPasswordEncoder();
+
+ assertThat(encoder.encode(null)).isNull();
+ }
+
+ @Test
+ void matchesWithNullOrEmptyValuesShouldReturnFalse() {
+ Pbkdf2Password4jPasswordEncoder encoder = new Pbkdf2Password4jPasswordEncoder();
+ String encoded = encoder.encode(PASSWORD);
+
+ assertThat(encoder.matches(null, encoded)).isFalse();
+ assertThat(encoder.matches("", encoded)).isFalse();
+ assertThat(encoder.matches(PASSWORD, null)).isFalse();
+ assertThat(encoder.matches(PASSWORD, "")).isFalse();
+ }
+
+}
diff --git a/crypto/src/test/java/org/springframework/security/crypto/password4j/ScryptPassword4jPasswordEncoderTests.java b/crypto/src/test/java/org/springframework/security/crypto/password4j/ScryptPassword4jPasswordEncoderTests.java
new file mode 100644
index 00000000000..cfbba9d5e89
--- /dev/null
+++ b/crypto/src/test/java/org/springframework/security/crypto/password4j/ScryptPassword4jPasswordEncoderTests.java
@@ -0,0 +1,248 @@
+/*
+ * Copyright 2004-present the original author or authors.
+ *
+ * Licensed 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
+ *
+ * https://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.springframework.security.crypto.password4j;
+
+import com.password4j.AlgorithmFinder;
+import com.password4j.ScryptFunction;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.CsvSource;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
+
+/**
+ * Tests for {@link ScryptPassword4jPasswordEncoder}.
+ *
+ * @author Mehrdad Bozorgmehr
+ */
+class ScryptPassword4jPasswordEncoderTests {
+
+ private static final String PASSWORD = "password";
+
+ private static final String LONG_PASSWORD = "a".repeat(1000);
+
+ private static final String SPECIAL_CHARS_PASSWORD = "p@ssw0rd!#$%^&*()_+-=[]{}|;':\",./<>?";
+
+ private static final String UNICODE_PASSWORD = "пароль密码パスワード🔐";
+
+ @Test
+ void defaultConstructorShouldCreateWorkingEncoder() {
+ ScryptPassword4jPasswordEncoder encoder = new ScryptPassword4jPasswordEncoder();
+
+ String encoded = encoder.encode(PASSWORD);
+
+ assertThat(encoded).isNotNull();
+ // Password4j scrypt format differs from classic $s0$; accept generic multi-part
+ // format
+ assertThat(encoded.split("\\$").length).isGreaterThanOrEqualTo(3);
+ assertThat(encoder.matches(PASSWORD, encoded)).isTrue();
+ }
+
+ @Test
+ void constructorWithNullScryptFunctionShouldThrowException() {
+ assertThatIllegalArgumentException().isThrownBy(() -> new ScryptPassword4jPasswordEncoder(null))
+ .withMessage("hashingFunction cannot be null");
+ }
+
+ @Test
+ void constructorWithCustomScryptFunctionShouldWork() {
+ ScryptFunction customFunction = ScryptFunction.getInstance(16384, 8, 1, 32);
+ ScryptPassword4jPasswordEncoder encoder = new ScryptPassword4jPasswordEncoder(customFunction);
+
+ String encoded = encoder.encode(PASSWORD);
+
+ assertThat(encoded).isNotNull();
+ assertThat(encoded.split("\\$").length).isGreaterThanOrEqualTo(3);
+ assertThat(encoder.matches(PASSWORD, encoded)).isTrue();
+ }
+
+ @ParameterizedTest
+ @CsvSource({ "1024, 8, 1, 16", "4096, 8, 1, 32", "16384, 8, 1, 32", "32768, 8, 1, 64" })
+ void encodingShouldWorkWithDifferentParameters(int N, int r, int p, int dkLen) {
+ ScryptFunction function = ScryptFunction.getInstance(N, r, p, dkLen);
+ ScryptPassword4jPasswordEncoder encoder = new ScryptPassword4jPasswordEncoder(function);
+
+ String encoded = encoder.encode(PASSWORD);
+
+ assertThat(encoded).isNotNull();
+ assertThat(encoded.split("\\$").length).isGreaterThanOrEqualTo(3);
+ assertThat(encoder.matches(PASSWORD, encoded)).isTrue();
+ }
+
+ @Test
+ void encodingShouldGenerateDifferentHashesForSamePassword() {
+ ScryptPassword4jPasswordEncoder encoder = new ScryptPassword4jPasswordEncoder();
+
+ 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();
+ }
+
+ @Test
+ void shouldHandleLongPasswords() {
+ ScryptPassword4jPasswordEncoder encoder = new ScryptPassword4jPasswordEncoder();
+
+ String encoded = encoder.encode(LONG_PASSWORD);
+
+ assertThat(encoder.matches(LONG_PASSWORD, encoded)).isTrue();
+ assertThat(encoder.matches("wrong", encoded)).isFalse();
+ }
+
+ @Test
+ void shouldHandleSpecialCharacters() {
+ ScryptPassword4jPasswordEncoder encoder = new ScryptPassword4jPasswordEncoder();
+
+ String encoded = encoder.encode(SPECIAL_CHARS_PASSWORD);
+
+ assertThat(encoder.matches(SPECIAL_CHARS_PASSWORD, encoded)).isTrue();
+ assertThat(encoder.matches("wrong", encoded)).isFalse();
+ }
+
+ @Test
+ void shouldHandleUnicodeCharacters() {
+ ScryptPassword4jPasswordEncoder encoder = new ScryptPassword4jPasswordEncoder();
+
+ String encoded = encoder.encode(UNICODE_PASSWORD);
+
+ assertThat(encoder.matches(UNICODE_PASSWORD, encoded)).isTrue();
+ assertThat(encoder.matches("wrong", encoded)).isFalse();
+ }
+
+ @Test
+ void shouldRejectIncorrectPasswords() {
+ ScryptPassword4jPasswordEncoder encoder = new ScryptPassword4jPasswordEncoder();
+ String encoded = encoder.encode(PASSWORD);
+
+ assertThat(encoder.matches("wrongpassword", encoded)).isFalse();
+ assertThat(encoder.matches("PASSWORD", encoded)).isFalse(); // Case sensitive
+ assertThat(encoder.matches("password ", encoded)).isFalse(); // Trailing space
+ assertThat(encoder.matches(" password", encoded)).isFalse(); // Leading space
+ }
+
+ @Test
+ void matchesShouldReturnFalseForNullOrEmptyInputs() {
+ ScryptPassword4jPasswordEncoder encoder = new ScryptPassword4jPasswordEncoder();
+ String encoded = encoder.encode(PASSWORD);
+
+ assertThat(encoder.matches(null, encoded)).isFalse();
+ assertThat(encoder.matches("", encoded)).isFalse();
+ assertThat(encoder.matches(PASSWORD, null)).isFalse();
+ assertThat(encoder.matches(PASSWORD, "")).isFalse();
+ assertThat(encoder.matches(null, null)).isFalse();
+ assertThat(encoder.matches("", "")).isFalse();
+ }
+
+ @Test
+ void encodeNullShouldReturnNull() {
+ ScryptPassword4jPasswordEncoder encoder = new ScryptPassword4jPasswordEncoder();
+
+ assertThat(encoder.encode(null)).isNull();
+ }
+
+ @Test
+ void upgradeEncodingShouldReturnFalse() {
+ ScryptPassword4jPasswordEncoder encoder = new ScryptPassword4jPasswordEncoder();
+ String encoded = encoder.encode(PASSWORD);
+
+ assertThat(encoder.upgradeEncoding(encoded)).isFalse();
+ }
+
+ @Test
+ void shouldWorkWithAlgorithmFinderDefaults() {
+ ScryptPassword4jPasswordEncoder encoder = new ScryptPassword4jPasswordEncoder(
+ AlgorithmFinder.getScryptInstance());
+
+ String encoded = encoder.encode(PASSWORD);
+
+ assertThat(encoded).isNotNull();
+ assertThat(encoder.matches(PASSWORD, encoded)).isTrue();
+ }
+
+ @Test
+ void shouldRejectMalformedHashes() {
+ ScryptPassword4jPasswordEncoder encoder = new ScryptPassword4jPasswordEncoder();
+ assertMalformedRejected(encoder, PASSWORD, "invalid_hash");
+ assertMalformedRejected(encoder, PASSWORD, "$s0$invalid");
+ assertMalformedRejected(encoder, PASSWORD, "");
+ }
+
+ private void assertMalformedRejected(ScryptPassword4jPasswordEncoder encoder, String raw, String malformed) {
+ boolean rejected;
+ try {
+ rejected = !encoder.matches(raw, malformed);
+ }
+ catch (RuntimeException ex) {
+ rejected = true; // exception path acceptable
+ }
+ assertThat(rejected).as("Malformed hash should not validate: " + malformed).isTrue();
+ }
+
+ @Test
+ void shouldHandleEmptyStringPassword() {
+ ScryptPassword4jPasswordEncoder encoder = new ScryptPassword4jPasswordEncoder();
+ String encoded = encoder.encode("");
+ assertThat(encoded).isNotNull();
+ boolean emptyMatches;
+ try {
+ emptyMatches = encoder.matches("", encoded);
+ }
+ catch (RuntimeException ex) {
+ emptyMatches = false;
+ }
+ if (emptyMatches) {
+ assertThat(encoder.matches("", encoded)).isTrue();
+ }
+ assertThat(encoder.matches("notEmpty", encoded)).isFalse();
+ }
+
+ @Test
+ void shouldHandleCustomCostParameters() {
+ // Test with low cost parameters for speed
+ ScryptFunction lowCost = ScryptFunction.getInstance(1024, 1, 1, 16);
+ // Test with higher cost parameters
+ ScryptFunction highCost = ScryptFunction.getInstance(32768, 8, 2, 64);
+
+ ScryptPassword4jPasswordEncoder lowEncoder = new ScryptPassword4jPasswordEncoder(lowCost);
+ ScryptPassword4jPasswordEncoder highEncoder = new ScryptPassword4jPasswordEncoder(highCost);
+
+ String lowEncoded = lowEncoder.encode(PASSWORD);
+ String highEncoded = highEncoder.encode(PASSWORD);
+
+ assertThat(lowEncoder.matches(PASSWORD, lowEncoded)).isTrue();
+ assertThat(highEncoder.matches(PASSWORD, highEncoded)).isTrue();
+
+ // Each encoder should work with hashes generated by the same parameters
+ assertThat(lowEncoded).isNotEqualTo(highEncoded);
+ }
+
+ @Test
+ void shouldHandleEdgeCaseParameters() {
+ // Test with minimum practical parameters
+ ScryptFunction minParams = ScryptFunction.getInstance(2, 1, 1, 1);
+ ScryptPassword4jPasswordEncoder encoder = new ScryptPassword4jPasswordEncoder(minParams);
+
+ String encoded = encoder.encode(PASSWORD);
+
+ assertThat(encoded).isNotNull();
+ assertThat(encoder.matches(PASSWORD, encoded)).isTrue();
+ }
+
+}
diff --git a/dependencies/spring-security-dependencies.gradle b/dependencies/spring-security-dependencies.gradle
index e0d976f235d..f14cbd3f34f 100644
--- a/dependencies/spring-security-dependencies.gradle
+++ b/dependencies/spring-security-dependencies.gradle
@@ -78,6 +78,6 @@ dependencies {
api libs.org.apache.maven.resolver.maven.resolver.transport.http
api libs.org.apache.maven.maven.resolver.provider
api libs.org.instancio.instancio.junit
+ api libs.com.password4j.password4j
}
}
-
diff --git a/docs/modules/ROOT/pages/features/authentication/password-storage.adoc b/docs/modules/ROOT/pages/features/authentication/password-storage.adoc
index 2ea6d68babb..48b6983433d 100644
--- a/docs/modules/ROOT/pages/features/authentication/password-storage.adoc
+++ b/docs/modules/ROOT/pages/features/authentication/password-storage.adoc
@@ -463,6 +463,115 @@ There are a significant number of other `PasswordEncoder` implementations that e
They are all deprecated to indicate that they are no longer considered secure.
However, there are no plans to remove them, since it is difficult to migrate existing legacy systems.
+[[password4j]]
+== Password4j-based Password Encoders
+
+Spring Security 7.0 introduces alternative password encoder implementations based on the https://github.com/Password4j/password4j[Password4j] library.
+These encoders provide additional options for popular hashing algorithms and can be used as alternatives to the existing Spring Security implementations.
+
+The Password4j library is a Java cryptographic library that focuses on password hashing with support for multiple algorithms.
+These encoders are particularly useful when you need specific algorithm configurations or want to leverage Password4j's optimizations.
+
+All Password4j-based encoders are thread-safe and can be shared across multiple threads.
+
+[[password4j-argon2]]
+=== Argon2Password4jPasswordEncoder
+
+The `Argon2Password4jPasswordEncoder` implementation uses the https://en.wikipedia.org/wiki/Argon2[Argon2] algorithm via the Password4j library to hash passwords.
+This provides an alternative to Spring Security's built-in `Argon2PasswordEncoder` with different configuration options and potential performance characteristics.
+
+Argon2 is the winner of the https://en.wikipedia.org/wiki/Password_Hashing_Competition[Password Hashing Competition] and is recommended for new applications.
+This implementation leverages Password4j's Argon2 support which properly includes the salt in the output hash.
+
+Create an encoder with default settings:
+
+.Argon2Password4jPasswordEncoder
+include-code::./Argon2UsageTests[tag=default-params,indent=0]
+
+Create an encoder with custom Argon2 parameters:
+
+.Argon2Password4jPasswordEncoder Custom
+include-code::./Argon2UsageTests[tag=custom-params,indent=0]
+
+[[password4j-bcrypt]]
+=== BcryptPassword4jPasswordEncoder
+
+The `BcryptPassword4jPasswordEncoder` implementation uses the https://en.wikipedia.org/wiki/Bcrypt[BCrypt] algorithm via the Password4j library to hash passwords.
+This provides an alternative to Spring Security's built-in `BCryptPasswordEncoder` with Password4j's implementation characteristics.
+
+BCrypt is a well-established password hashing algorithm that includes built-in salt generation and is resistant to rainbow table attacks.
+This implementation leverages Password4j's BCrypt support which properly includes the salt in the output hash.
+
+Create an encoder with default settings:
+
+.BcryptPassword4jPasswordEncoder
+include-code::./BcryptUsageTests[tag=default-params,indent=0]
+
+Create an encoder with custom bcrypt parameters:
+
+.BcryptPassword4jPasswordEncoder Custom
+include-code::./BcryptUsageTests[tag=custom-params,indent=0]
+
+[[password4j-scrypt]]
+=== ScryptPassword4jPasswordEncoder
+
+The `ScryptPassword4jPasswordEncoder` implementation uses the https://en.wikipedia.org/wiki/Scrypt[SCrypt] algorithm via the Password4j library to hash passwords.
+This provides an alternative to Spring Security's built-in `SCryptPasswordEncoder` with Password4j's implementation characteristics.
+
+SCrypt is a memory-hard password hashing algorithm designed to be resistant to hardware brute-force attacks.
+This implementation leverages Password4j's SCrypt support which properly includes the salt in the output hash.
+
+
+Create an encoder with default settings:
+
+.ScryptPassword4jPasswordEncoder
+include-code::./ScryptUsageTests[tag=default-params,indent=0]
+
+Create an encoder with custom scrypt parameters:
+
+.ScryptPassword4jPasswordEncoder Custom
+include-code::./ScryptUsageTests[tag=custom-params,indent=0]
+
+[[password4j-pbkdf2]]
+=== Pbkdf2Password4jPasswordEncoder
+
+The `Pbkdf2Password4jPasswordEncoder` implementation uses the https://en.wikipedia.org/wiki/PBKDF2[PBKDF2] algorithm via the Password4j library to hash passwords.
+This provides an alternative to Spring Security's built-in `Pbkdf2PasswordEncoder` with explicit salt management.
+
+PBKDF2 is a key derivation function designed to be computationally expensive to thwart dictionary and brute force attacks.
+This implementation handles salt management explicitly since Password4j's PBKDF2 implementation does not include the salt in the output hash.
+The encoded password format is: `+{salt}:{hash}+` where both salt and hash are Base64 encoded.
+
+Create an encoder with default settings:
+
+.Pbkdf2Password4jPasswordEncoder
+include-code::./Pbkdf2UsageTests[tag=default-params,indent=0]
+
+Create an encoder with custom PBKDF2 parameters:
+
+.Pbkdf2Password4jPasswordEncoder Custom
+include-code::./Pbkdf2UsageTests[tag=custom-params,indent=0]
+
+[[password4j-ballooning]]
+=== BalloonHashingPassword4jPasswordEncoder
+
+The `BalloonHashingPassword4jPasswordEncoder` implementation uses the Balloon hashing algorithm via the Password4j library to hash passwords.
+Balloon hashing is a memory-hard password hashing algorithm designed to be resistant to both time-memory trade-off attacks and side-channel attacks.
+
+This implementation handles salt management explicitly since Password4j's Balloon hashing implementation does not include the salt in the output hash.
+The encoded password format is: `+{salt}:{hash}+` where both salt and hash are Base64 encoded.
+
+
+Create an encoder with default settings:
+
+.BalloonHashingPassword4jPasswordEncoder
+include-code::./BallooningHashingUsageTests[tag=default-params,indent=0]
+
+Create an encoder with custom parameters:
+
+.BalloonHashingPassword4jPasswordEncoder Custom
+include-code::./BallooningHashingUsageTests[tag=custom-params,indent=0]
+
[[authentication-password-storage-configuration]]
== Password Storage Configuration
diff --git a/docs/modules/ROOT/pages/whats-new.adoc b/docs/modules/ROOT/pages/whats-new.adoc
index 2955ebb821f..c150e561132 100644
--- a/docs/modules/ROOT/pages/whats-new.adoc
+++ b/docs/modules/ROOT/pages/whats-new.adoc
@@ -35,6 +35,15 @@ Java::
http.csrf((csrf) -> csrf.spa());
----
+== Crypto
+
+* Added Password4j-based password encoders providing alternative implementations for popular hashing algorithms:
+** `Argon2Password4jPasswordEncoder` - xref:features/authentication/password-storage.adoc#password4j-argon2[Argon2]
+** `BcryptPassword4jPasswordEncoder` - xref:features/authentication/password-storage.adoc#password4j-bcrypt[BCrypt]
+** `ScryptPassword4jPasswordEncoder` - xref:features/authentication/password-storage.adoc#password4j-scrypt[SCrypt]
+** `Pbkdf2Password4jPasswordEncoder` - xref:features/authentication/password-storage.adoc#password4j-pbkdf2[PBKDF2]
+** `BalloonHashingPassword4jPasswordEncoder` - xref:features/authentication/password-storage.adoc#password4j-balloon[Balloon Hashing]
+
== Data
* Added support to Authorized objects for Spring Data types
diff --git a/docs/spring-security-docs.gradle b/docs/spring-security-docs.gradle
index 002612d8b15..db88c19fdc6 100644
--- a/docs/spring-security-docs.gradle
+++ b/docs/spring-security-docs.gradle
@@ -39,6 +39,7 @@ dependencies {
testImplementation project(':spring-security-test')
testImplementation project(':spring-security-oauth2-client')
testImplementation 'com.squareup.okhttp3:mockwebserver'
+ testImplementation libs.com.password4j.password4j
testImplementation 'com.unboundid:unboundid-ldapsdk'
testImplementation libs.webauthn4j.core
testImplementation 'org.jetbrains.kotlin:kotlin-reflect'
diff --git a/docs/src/test/java/org/springframework/security/docs/features/authentication/password4jargon2/Argon2UsageTests.java b/docs/src/test/java/org/springframework/security/docs/features/authentication/password4jargon2/Argon2UsageTests.java
new file mode 100644
index 00000000000..be705e8b36d
--- /dev/null
+++ b/docs/src/test/java/org/springframework/security/docs/features/authentication/password4jargon2/Argon2UsageTests.java
@@ -0,0 +1,53 @@
+/*
+ * Copyright 2004-present the original author or authors.
+ *
+ * Licensed 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
+ *
+ * https://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.springframework.security.docs.features.authentication.password4jargon2;
+
+import com.password4j.Argon2Function;
+import com.password4j.types.Argon2;
+import org.junit.jupiter.api.Test;
+
+import org.springframework.security.crypto.password.PasswordEncoder;
+import org.springframework.security.crypto.password4j.Argon2Password4jPasswordEncoder;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * @author Rob Winch
+ */
+public class Argon2UsageTests {
+
+ @Test
+ void defaultParams() {
+ // tag::default-params[]
+ PasswordEncoder encoder = new Argon2Password4jPasswordEncoder();
+ String result = encoder.encode("myPassword");
+ assertThat(encoder.matches("myPassword", result)).isTrue();
+ // end::default-params[]
+ }
+
+ @Test
+ void customParameters() {
+ // tag::custom-params[]
+ Argon2Function argon2Fn = Argon2Function.getInstance(65536, 3, 4, 32,
+ Argon2.ID);
+ PasswordEncoder encoder = new Argon2Password4jPasswordEncoder(argon2Fn);
+ String result = encoder.encode("myPassword");
+ assertThat(encoder.matches("myPassword", result)).isTrue();
+ // end::custom-params[]
+ }
+
+}
diff --git a/docs/src/test/java/org/springframework/security/docs/features/authentication/password4jballooning/BallooningHashingUsageTests.java b/docs/src/test/java/org/springframework/security/docs/features/authentication/password4jballooning/BallooningHashingUsageTests.java
new file mode 100644
index 00000000000..ce9b22d5f08
--- /dev/null
+++ b/docs/src/test/java/org/springframework/security/docs/features/authentication/password4jballooning/BallooningHashingUsageTests.java
@@ -0,0 +1,52 @@
+/*
+ * Copyright 2004-present the original author or authors.
+ *
+ * Licensed 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
+ *
+ * https://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.springframework.security.docs.features.authentication.password4jballooning;
+
+import com.password4j.BalloonHashingFunction;
+import org.junit.jupiter.api.Test;
+
+import org.springframework.security.crypto.password.PasswordEncoder;
+import org.springframework.security.crypto.password4j.BalloonHashingPassword4jPasswordEncoder;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * @author Rob Winch
+ */
+public class BallooningHashingUsageTests {
+
+ @Test
+ void defaultParams() {
+ // tag::default-params[]
+ PasswordEncoder encoder = new BalloonHashingPassword4jPasswordEncoder();
+ String result = encoder.encode("myPassword");
+ assertThat(encoder.matches("myPassword", result)).isTrue();
+ // end::default-params[]
+ }
+
+ @Test
+ void customParameters() {
+ // tag::custom-params[]
+ BalloonHashingFunction ballooningHashingFn =
+ BalloonHashingFunction.getInstance("SHA-256", 1024, 3, 4, 3);
+ PasswordEncoder encoder = new BalloonHashingPassword4jPasswordEncoder(ballooningHashingFn);
+ String result = encoder.encode("myPassword");
+ assertThat(encoder.matches("myPassword", result)).isTrue();
+ // end::custom-params[]
+ }
+
+}
diff --git a/docs/src/test/java/org/springframework/security/docs/features/authentication/password4jbcrypt/BcryptUsageTests.java b/docs/src/test/java/org/springframework/security/docs/features/authentication/password4jbcrypt/BcryptUsageTests.java
new file mode 100644
index 00000000000..f7921bf20d2
--- /dev/null
+++ b/docs/src/test/java/org/springframework/security/docs/features/authentication/password4jbcrypt/BcryptUsageTests.java
@@ -0,0 +1,52 @@
+/*
+ * Copyright 2004-present the original author or authors.
+ *
+ * Licensed 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
+ *
+ * https://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.springframework.security.docs.features.authentication.password4jbcrypt;
+
+import com.password4j.BcryptFunction;
+import org.junit.jupiter.api.Test;
+
+import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
+import org.springframework.security.crypto.password.PasswordEncoder;
+import org.springframework.security.crypto.password4j.BcryptPassword4jPasswordEncoder;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * @author Rob Winch
+ */
+public class BcryptUsageTests {
+
+ @Test
+ void defaultParams() {
+ // tag::default-params[]
+ PasswordEncoder encoder = new BCryptPasswordEncoder();
+ String result = encoder.encode("myPassword");
+ assertThat(encoder.matches("myPassword", result)).isTrue();
+ // end::default-params[]
+ }
+
+ @Test
+ void customParameters() {
+ // tag::custom-params[]
+ BcryptFunction bcryptFn = BcryptFunction.getInstance(12);
+ PasswordEncoder encoder = new BcryptPassword4jPasswordEncoder(bcryptFn);
+ String result = encoder.encode("myPassword");
+ assertThat(encoder.matches("myPassword", result)).isTrue();
+ // end::custom-params[]
+ }
+
+}
diff --git a/docs/src/test/java/org/springframework/security/docs/features/authentication/password4jpbkdf2/Pbkdf2UsageTests.java b/docs/src/test/java/org/springframework/security/docs/features/authentication/password4jpbkdf2/Pbkdf2UsageTests.java
new file mode 100644
index 00000000000..93a358b3d1f
--- /dev/null
+++ b/docs/src/test/java/org/springframework/security/docs/features/authentication/password4jpbkdf2/Pbkdf2UsageTests.java
@@ -0,0 +1,52 @@
+/*
+ * Copyright 2004-present the original author or authors.
+ *
+ * Licensed 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
+ *
+ * https://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.springframework.security.docs.features.authentication.password4jpbkdf2;
+
+import com.password4j.PBKDF2Function;
+import com.password4j.types.Hmac;
+import org.junit.jupiter.api.Test;
+
+import org.springframework.security.crypto.password.PasswordEncoder;
+import org.springframework.security.crypto.password4j.Pbkdf2Password4jPasswordEncoder;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * @author Rob Winch
+ */
+public class Pbkdf2UsageTests {
+
+ @Test
+ void defaultParams() {
+ // tag::default-params[]
+ PasswordEncoder encoder = new Pbkdf2Password4jPasswordEncoder();
+ String result = encoder.encode("myPassword");
+ assertThat(encoder.matches("myPassword", result)).isTrue();
+ // end::default-params[]
+ }
+
+ @Test
+ void customParameters() {
+ // tag::custom-params[]
+ PBKDF2Function pbkdf2Fn = PBKDF2Function.getInstance(Hmac.SHA256, 100000, 256);
+ PasswordEncoder encoder = new Pbkdf2Password4jPasswordEncoder(pbkdf2Fn);
+ String result = encoder.encode("myPassword");
+ assertThat(encoder.matches("myPassword", result)).isTrue();
+ // end::custom-params[]
+ }
+
+}
diff --git a/docs/src/test/java/org/springframework/security/docs/features/authentication/password4jscrypt/ScryptUsageTests.java b/docs/src/test/java/org/springframework/security/docs/features/authentication/password4jscrypt/ScryptUsageTests.java
new file mode 100644
index 00000000000..ba6cda784bb
--- /dev/null
+++ b/docs/src/test/java/org/springframework/security/docs/features/authentication/password4jscrypt/ScryptUsageTests.java
@@ -0,0 +1,51 @@
+/*
+ * Copyright 2004-present the original author or authors.
+ *
+ * Licensed 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
+ *
+ * https://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.springframework.security.docs.features.authentication.password4jscrypt;
+
+import com.password4j.ScryptFunction;
+import org.junit.jupiter.api.Test;
+
+import org.springframework.security.crypto.password.PasswordEncoder;
+import org.springframework.security.crypto.password4j.ScryptPassword4jPasswordEncoder;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * @author Rob Winch
+ */
+public class ScryptUsageTests {
+
+ @Test
+ void defaultParams() {
+ // tag::default-params[]
+ PasswordEncoder encoder = new ScryptPassword4jPasswordEncoder();
+ String result = encoder.encode("myPassword");
+ assertThat(encoder.matches("myPassword", result)).isTrue();
+ // end::default-params[]
+ }
+
+ @Test
+ void customParameters() {
+ // tag::custom-params[]
+ ScryptFunction scryptFn = ScryptFunction.getInstance(32768, 8, 1, 32);
+ PasswordEncoder encoder = new ScryptPassword4jPasswordEncoder(scryptFn);
+ String result = encoder.encode("myPassword");
+ assertThat(encoder.matches("myPassword", result)).isTrue();
+ // end::custom-params[]
+ }
+
+}
diff --git a/docs/src/test/kotlin/org/springframework/security/kt/docs/features/authentication/password4jargon2/Argon2UsageTests.kt b/docs/src/test/kotlin/org/springframework/security/kt/docs/features/authentication/password4jargon2/Argon2UsageTests.kt
new file mode 100644
index 00000000000..a60cb45d725
--- /dev/null
+++ b/docs/src/test/kotlin/org/springframework/security/kt/docs/features/authentication/password4jargon2/Argon2UsageTests.kt
@@ -0,0 +1,51 @@
+/*
+ * Copyright 2004-present the original author or authors.
+ *
+ * Licensed 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
+ *
+ * https://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.springframework.security.kt.docs.features.authentication.password4jargon2
+
+import com.password4j.Argon2Function
+import com.password4j.types.Argon2
+import org.assertj.core.api.Assertions.assertThat
+import org.junit.jupiter.api.Test
+import org.springframework.security.crypto.password.PasswordEncoder
+import org.springframework.security.crypto.password4j.Argon2Password4jPasswordEncoder
+
+/**
+ * @author Rob Winch
+ */
+class Argon2UsageTests {
+
+ @Test
+ fun defaultParams() {
+ // tag::default-params[]
+ val encoder: PasswordEncoder = Argon2Password4jPasswordEncoder()
+ val result = encoder.encode("myPassword")
+ assertThat(encoder.matches("myPassword", result)).isTrue()
+ // end::default-params[]
+ }
+
+ @Test
+ fun customParameters() {
+ // tag::custom-params[]
+ val argon2Fn = Argon2Function.getInstance(
+ 65536, 3, 4, 32,
+ Argon2.ID
+ )
+ val encoder: PasswordEncoder = Argon2Password4jPasswordEncoder(argon2Fn)
+ val result = encoder.encode("myPassword")
+ assertThat(encoder.matches("myPassword", result)).isTrue()
+ // end::custom-params[]
+ }
+}
diff --git a/docs/src/test/kotlin/org/springframework/security/kt/docs/features/authentication/password4jballooning/BallooningHashingUsageTests.kt b/docs/src/test/kotlin/org/springframework/security/kt/docs/features/authentication/password4jballooning/BallooningHashingUsageTests.kt
new file mode 100644
index 00000000000..4aeb1f78c6d
--- /dev/null
+++ b/docs/src/test/kotlin/org/springframework/security/kt/docs/features/authentication/password4jballooning/BallooningHashingUsageTests.kt
@@ -0,0 +1,47 @@
+/*
+ * Copyright 2004-present the original author or authors.
+ *
+ * Licensed 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
+ *
+ * https://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.springframework.security.kt.docs.features.authentication.password4jballooning
+
+import com.password4j.BalloonHashingFunction
+import org.assertj.core.api.Assertions
+import org.junit.jupiter.api.Test
+import org.springframework.security.crypto.password.PasswordEncoder
+import org.springframework.security.crypto.password4j.BalloonHashingPassword4jPasswordEncoder
+
+/**
+ * @author Rob Winch
+ */
+class BallooningHashingUsageTests {
+ @Test
+ fun defaultParams() {
+ // tag::default-params[]
+ val encoder: PasswordEncoder = BalloonHashingPassword4jPasswordEncoder()
+ val result = encoder.encode("myPassword")
+ Assertions.assertThat(encoder.matches("myPassword", result)).isTrue()
+ // end::default-params[]
+ }
+
+ @Test
+ fun customParameters() {
+ // tag::custom-params[]
+ val ballooningHashingFn =
+ BalloonHashingFunction.getInstance("SHA-256", 1024, 3, 4, 3)
+ val encoder: PasswordEncoder = BalloonHashingPassword4jPasswordEncoder(ballooningHashingFn)
+ val result = encoder.encode("myPassword")
+ Assertions.assertThat(encoder.matches("myPassword", result)).isTrue()
+ // end::custom-params[]
+ }
+}
diff --git a/docs/src/test/kotlin/org/springframework/security/kt/docs/features/authentication/password4jbcrypt/BcryptUsageTests.kt b/docs/src/test/kotlin/org/springframework/security/kt/docs/features/authentication/password4jbcrypt/BcryptUsageTests.kt
new file mode 100644
index 00000000000..290fb819902
--- /dev/null
+++ b/docs/src/test/kotlin/org/springframework/security/kt/docs/features/authentication/password4jbcrypt/BcryptUsageTests.kt
@@ -0,0 +1,32 @@
+package org.springframework.security.kt.docs.features.authentication.password4jbcrypt
+
+import com.password4j.BcryptFunction
+import org.assertj.core.api.Assertions
+import org.junit.jupiter.api.Test
+import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder
+import org.springframework.security.crypto.password.PasswordEncoder
+import org.springframework.security.crypto.password4j.BcryptPassword4jPasswordEncoder
+
+/**
+ * @author Rob Winch
+ */
+class BcryptUsageTests {
+ @Test
+ fun defaultParams() {
+ // tag::default-params[]
+ val encoder: PasswordEncoder = BCryptPasswordEncoder()
+ val result = encoder.encode("myPassword")
+ Assertions.assertThat(encoder.matches("myPassword", result)).isTrue()
+ // end::default-params[]
+ }
+
+ @Test
+ fun customParameters() {
+ // tag::custom-params[]
+ val bcryptFunction = BcryptFunction.getInstance(12)
+ val encoder: PasswordEncoder = BcryptPassword4jPasswordEncoder(bcryptFunction)
+ val result = encoder.encode("myPassword")
+ Assertions.assertThat(encoder.matches("myPassword", result)).isTrue()
+ // end::custom-params[]
+ }
+}
diff --git a/docs/src/test/kotlin/org/springframework/security/kt/docs/features/authentication/password4jpbkdf2/Pbkdf2UsageTests.kt b/docs/src/test/kotlin/org/springframework/security/kt/docs/features/authentication/password4jpbkdf2/Pbkdf2UsageTests.kt
new file mode 100644
index 00000000000..622802031bc
--- /dev/null
+++ b/docs/src/test/kotlin/org/springframework/security/kt/docs/features/authentication/password4jpbkdf2/Pbkdf2UsageTests.kt
@@ -0,0 +1,32 @@
+package org.springframework.security.kt.docs.features.authentication.password4jpbkdf2
+
+import com.password4j.PBKDF2Function
+import com.password4j.types.Hmac
+import org.assertj.core.api.Assertions
+import org.junit.jupiter.api.Test
+import org.springframework.security.crypto.password.PasswordEncoder
+import org.springframework.security.crypto.password4j.Pbkdf2Password4jPasswordEncoder
+
+/**
+ * @author Rob Winch
+ */
+class Pbkdf2UsageTests {
+ @Test
+ fun defaultParams() {
+ // tag::default-params[]
+ val encoder: PasswordEncoder = Pbkdf2Password4jPasswordEncoder()
+ val result = encoder.encode("myPassword")
+ Assertions.assertThat(encoder.matches("myPassword", result)).isTrue()
+ // end::default-params[]
+ }
+
+ @Test
+ fun customParameters() {
+ // tag::custom-params[]
+ val pbkdf2Fn = PBKDF2Function.getInstance(Hmac.SHA256, 100000, 256)
+ val encoder: PasswordEncoder = Pbkdf2Password4jPasswordEncoder(pbkdf2Fn)
+ val result = encoder.encode("myPassword")
+ Assertions.assertThat(encoder.matches("myPassword", result)).isTrue()
+ // end::custom-params[]
+ }
+}
diff --git a/docs/src/test/kotlin/org/springframework/security/kt/docs/features/authentication/password4jscrypt/ScryptUsageTests.kt b/docs/src/test/kotlin/org/springframework/security/kt/docs/features/authentication/password4jscrypt/ScryptUsageTests.kt
new file mode 100644
index 00000000000..d7d29142362
--- /dev/null
+++ b/docs/src/test/kotlin/org/springframework/security/kt/docs/features/authentication/password4jscrypt/ScryptUsageTests.kt
@@ -0,0 +1,31 @@
+package org.springframework.security.kt.docs.features.authentication.password4jscrypt
+
+import com.password4j.ScryptFunction
+import org.assertj.core.api.Assertions
+import org.junit.jupiter.api.Test
+import org.springframework.security.crypto.password.PasswordEncoder
+import org.springframework.security.crypto.password4j.ScryptPassword4jPasswordEncoder
+
+/**
+ * @author Rob Winch
+ */
+class ScryptUsageTests {
+ @Test
+ fun defaultParams() {
+ // tag::default-params[]
+ val encoder: PasswordEncoder = ScryptPassword4jPasswordEncoder()
+ val result = encoder.encode("myPassword")
+ Assertions.assertThat(encoder.matches("myPassword", result)).isTrue()
+ // end::default-params[]
+ }
+
+ @Test
+ fun customParameters() {
+ // tag::custom-params[]
+ val scryptFn = ScryptFunction.getInstance(32768, 8, 1, 32)
+ val encoder: PasswordEncoder = ScryptPassword4jPasswordEncoder(scryptFn)
+ val result = encoder.encode("myPassword")
+ Assertions.assertThat(encoder.matches("myPassword", result)).isTrue()
+ // end::custom-params[]
+ }
+}
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 63f14d68933..9558e11acc1 100644
--- a/gradle/libs.versions.toml
+++ b/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]