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]