From bd593a63d073361f7d33ed5f10168fcdd69f1b83 Mon Sep 17 00:00:00 2001 From: "M.Bozorgmehr" Date: Sun, 7 Sep 2025 11:33:14 +0330 Subject: [PATCH 1/9] Refactor Password4jPasswordEncoder to use AlgorithmFinder for algorithm selection and enhance documentation Closes gh-17706 Signed-off-by: M.Bozorgmehr Add Password4jPasswordEncoder for enhanced password hashing support Signed-off-by: M.Bozorgmehr Signed-off-by: M.Bozorgmehr Add Password4jPasswordEncoder for enhanced password hashing support Signed-off-by: M.Bozorgmehr Signed-off-by: Mehrdad Signed-off-by: M.Bozorgmehr --- crypto/spring-security-crypto.gradle | 1 + .../factory/PasswordEncoderFactories.java | 13 + .../password4j/Password4jPasswordEncoder.java | 249 ++++++++ .../crypto/password4j/package-info.java | 21 + .../Password4jPasswordEncoderTests.java | 596 ++++++++++++++++++ .../PasswordCompatibilityTests.java | 160 +++++ .../spring-security-dependencies.gradle | 2 +- 7 files changed, 1041 insertions(+), 1 deletion(-) create mode 100644 crypto/src/main/java/org/springframework/security/crypto/password4j/Password4jPasswordEncoder.java create mode 100644 crypto/src/main/java/org/springframework/security/crypto/password4j/package-info.java create mode 100644 crypto/src/test/java/org/springframework/security/crypto/password4j/Password4jPasswordEncoderTests.java create mode 100644 crypto/src/test/java/org/springframework/security/crypto/password4j/PasswordCompatibilityTests.java diff --git a/crypto/spring-security-crypto.gradle b/crypto/spring-security-crypto.gradle index 8370c1324c7..6b2b478422b 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 '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/factory/PasswordEncoderFactories.java b/crypto/src/main/java/org/springframework/security/crypto/factory/PasswordEncoderFactories.java index b6c6f2f3e52..7704f6f2101 100644 --- a/crypto/src/main/java/org/springframework/security/crypto/factory/PasswordEncoderFactories.java +++ b/crypto/src/main/java/org/springframework/security/crypto/factory/PasswordEncoderFactories.java @@ -24,6 +24,7 @@ import org.springframework.security.crypto.password.DelegatingPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.crypto.password.Pbkdf2PasswordEncoder; +import org.springframework.security.crypto.password4j.Password4jPasswordEncoder; import org.springframework.security.crypto.scrypt.SCryptPasswordEncoder; /** @@ -65,6 +66,10 @@ private PasswordEncoderFactories() { *
  • argon2 - {@link Argon2PasswordEncoder#defaultsForSpringSecurity_v5_2()}
  • *
  • argon2@SpringSecurity_v5_8 - * {@link Argon2PasswordEncoder#defaultsForSpringSecurity_v5_8()}
  • + *
  • password4j-bcrypt - {@link Password4jPasswordEncoder} with BCrypt
  • + *
  • password4j-scrypt - {@link Password4jPasswordEncoder} with SCrypt
  • + *
  • password4j-argon2 - {@link Password4jPasswordEncoder} with Argon2
  • + *
  • password4j-pbkdf2 - {@link Password4jPasswordEncoder} with PBKDF2
  • * * @return the {@link PasswordEncoder} to use */ @@ -87,6 +92,14 @@ public static PasswordEncoder createDelegatingPasswordEncoder() { encoders.put("sha256", new org.springframework.security.crypto.password.StandardPasswordEncoder()); encoders.put("argon2", Argon2PasswordEncoder.defaultsForSpringSecurity_v5_2()); encoders.put("argon2@SpringSecurity_v5_8", Argon2PasswordEncoder.defaultsForSpringSecurity_v5_8()); + + // Password4j implementations + encoders.put("password4j-bcrypt", Password4jPasswordEncoder.bcrypt(10)); + encoders.put("password4j-scrypt", Password4jPasswordEncoder.scrypt(16384, 8, 1, 32)); + encoders.put("password4j-argon2", Password4jPasswordEncoder.argon2(65536, 3, 4, 32, + com.password4j.types.Argon2.ID)); + encoders.put("password4j-pbkdf2", Password4jPasswordEncoder.pbkdf2(310000, 32)); + return new DelegatingPasswordEncoder(encodingId, encoders); } 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..402fb4d42b7 --- /dev/null +++ b/crypto/src/main/java/org/springframework/security/crypto/password4j/Password4jPasswordEncoder.java @@ -0,0 +1,249 @@ +/* + * 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.*; +import com.password4j.types.Argon2; +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.springframework.security.crypto.password.AbstractValidatingPasswordEncoder; +import org.springframework.util.Assert; + +/** + * Implementation of {@link org.springframework.security.crypto.password.PasswordEncoder} that uses the Password4j library. + * This encoder supports multiple password hashing algorithms including BCrypt, SCrypt, Argon2, and PBKDF2. + * + *

    The encoder determines the algorithm used based on the algorithm type specified during construction. + * For verification, it can automatically detect the algorithm used in existing hashes.

    + * + *

    This implementation is thread-safe and can be shared across multiple threads.

    + * + * @author Mehrdad Bozorgmehr + * @since 6.5 + */ +public class Password4jPasswordEncoder extends AbstractValidatingPasswordEncoder { + + private final Log logger = LogFactory.getLog(getClass()); + + private final HashingFunction hashingFunction; + + private final Password4jAlgorithm algorithm; + + + /** + * Enumeration of supported Password4j algorithms. + */ + public enum Password4jAlgorithm { + /** + * BCrypt algorithm. + */ + BCRYPT, + /** + * SCrypt algorithm. + */ + SCRYPT, + /** + * Argon2 algorithm. + */ + ARGON2, + /** + * PBKDF2 algorithm. + */ + PBKDF2, + /** + * Compressed PBKDF2 algorithm. + */ + COMPRESSED_PBKDF2 + } + + /** + * Constructs a Password4j password encoder with the default BCrypt algorithm. + */ + public Password4jPasswordEncoder() { + this(Password4jAlgorithm.BCRYPT); + } + + /** + * Constructs a Password4j password encoder with the specified algorithm using default parameters. + * + * @param algorithm the password hashing algorithm to use + */ + public Password4jPasswordEncoder(Password4jAlgorithm algorithm) { + Assert.notNull(algorithm, "algorithm cannot be null"); + this.algorithm = algorithm; + this.hashingFunction = createDefaultHashingFunction(algorithm); + } + + /** + * Constructs a Password4j password encoder with a custom hashing function. + * + * @param hashingFunction the custom hashing function to use + * @param algorithm the password hashing algorithm type + */ + public Password4jPasswordEncoder(HashingFunction hashingFunction, Password4jAlgorithm algorithm) { + Assert.notNull(hashingFunction, "hashingFunction cannot be null"); + Assert.notNull(algorithm, "algorithm cannot be null"); + this.hashingFunction = hashingFunction; + this.algorithm = algorithm; + } + + /** + * Creates a Password4j password encoder with BCrypt algorithm and specified rounds. + * + * @param rounds the number of rounds (cost factor) for BCrypt + * @return a new Password4j password encoder + */ + public static Password4jPasswordEncoder bcrypt(int rounds) { + return new Password4jPasswordEncoder(BcryptFunction.getInstance(rounds), Password4jAlgorithm.BCRYPT); + } + + /** + * Creates a Password4j password encoder with SCrypt algorithm and specified parameters. + * + * @param workFactor the work factor (N parameter) + * @param resources the resources (r parameter) + * @param parallelization the parallelization (p parameter) + * @param derivedKeyLength the derived key length + * @return a new Password4j password encoder + */ + public static Password4jPasswordEncoder scrypt(int workFactor, int resources, int parallelization, int derivedKeyLength) { + return new Password4jPasswordEncoder( + ScryptFunction.getInstance(workFactor, resources, parallelization, derivedKeyLength), + Password4jAlgorithm.SCRYPT + ); + } + + /** + * Creates a Password4j password encoder with Argon2 algorithm and specified parameters. + * + * @param memory the memory cost + * @param iterations the number of iterations + * @param parallelism the parallelism + * @param outputLength the output length + * @param type the Argon2 type + * @return a new Password4j password encoder + */ + public static Password4jPasswordEncoder argon2(int memory, int iterations, int parallelism, int outputLength, Argon2 type) { + return new Password4jPasswordEncoder( + Argon2Function.getInstance(memory, iterations, parallelism, outputLength, type), + Password4jAlgorithm.ARGON2 + ); + } + + /** + * Creates a Password4j password encoder with PBKDF2 algorithm and specified parameters. + * + * @param iterations the number of iterations + * @param derivedKeyLength the derived key length + * @return a new Password4j password encoder + */ + public static Password4jPasswordEncoder pbkdf2(int iterations, int derivedKeyLength) { + return new Password4jPasswordEncoder( + CompressedPBKDF2Function.getInstance("SHA256", iterations, derivedKeyLength), + Password4jAlgorithm.PBKDF2 + ); + } + + /** + * Creates a Password4j password encoder with compressed PBKDF2 algorithm. + * + * @param iterations the number of iterations + * @param derivedKeyLength the derived key length + * @return a new Password4j password encoder + */ + public static Password4jPasswordEncoder compressedPbkdf2(int iterations, int derivedKeyLength) { + return new Password4jPasswordEncoder( + CompressedPBKDF2Function.getInstance("SHA256", iterations, derivedKeyLength), + Password4jAlgorithm.COMPRESSED_PBKDF2 + ); + } + + /** + * Creates a Password4j password encoder with default settings for Spring Security v5.8+. + * This uses BCrypt with 10 rounds. + * + * @return a new Password4j password encoder with recommended defaults + * @since 6.5 + */ + public static Password4jPasswordEncoder defaultsForSpringSecurity() { + return bcrypt(10); + } + + @Override + protected String encodeNonNullPassword(String rawPassword) { + try { + Hash hash = Password.hash(rawPassword).with(this.hashingFunction); + return hash.getResult(); + } catch (Exception ex) { + throw new IllegalStateException("Failed to encode password using Password4j", ex); + } + } + + @Override + protected boolean matchesNonNull(String rawPassword, String encodedPassword) { + try { + // Use the specific hashing function for verification + return Password.check(rawPassword, encodedPassword).with(this.hashingFunction); + } catch (Exception ex) { + this.logger.warn("Password verification failed for encoded password: " + encodedPassword, ex); + return false; + } + } + + @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; + } + + /** + * Creates a default hashing function for the specified algorithm. + * + * @param algorithm the password hashing algorithm + * @return the default hashing function + */ + private static HashingFunction createDefaultHashingFunction(Password4jAlgorithm algorithm) { + return switch (algorithm) { + case BCRYPT -> BcryptFunction.getInstance(10); // Default 10 rounds + case SCRYPT -> ScryptFunction.getInstance(16384, 8, 1, 32); // Default parameters + case ARGON2 -> Argon2Function.getInstance(65536, 3, 4, 32, Argon2.ID); // Default parameters + case PBKDF2 -> + CompressedPBKDF2Function.getInstance("SHA256", 310000, 32); // Use compressed format for self-contained encoding + case COMPRESSED_PBKDF2 -> CompressedPBKDF2Function.getInstance("SHA256", 310000, 32); + }; + } + + /** + * Gets the algorithm used by this encoder. + * + * @return the password hashing algorithm + */ + public Password4jAlgorithm getAlgorithm() { + return this.algorithm; + } + + /** + * Gets the hashing function used by this encoder. + * + * @return the hashing function + */ + public HashingFunction getHashingFunction() { + return this.hashingFunction; + } + +} 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..f15bf9e10b0 --- /dev/null +++ b/crypto/src/main/java/org/springframework/security/crypto/password4j/package-info.java @@ -0,0 +1,21 @@ +/* + * 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/Password4jPasswordEncoderTests.java b/crypto/src/test/java/org/springframework/security/crypto/password4j/Password4jPasswordEncoderTests.java new file mode 100644 index 00000000000..93c6e90f451 --- /dev/null +++ b/crypto/src/test/java/org/springframework/security/crypto/password4j/Password4jPasswordEncoderTests.java @@ -0,0 +1,596 @@ +/* + * 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.CompressedPBKDF2Function; +import com.password4j.ScryptFunction; +import com.password4j.types.Argon2; +import org.junit.jupiter.api.RepeatedTest; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.EnumSource; +import org.junit.jupiter.params.provider.ValueSource; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; + +/** + * Tests for {@link Password4jPasswordEncoder}. + * + * @author Mehrdad Bozorgmehr + */ +class Password4jPasswordEncoderTests { + + private static final String PASSWORD = "password"; + private static final String WRONG_PASSWORD = "wrongpassword"; + private static final String UNICODE_PASSWORD = "пароль123🔐"; + private static final String LONG_PASSWORD = "a".repeat(1000); + + // Constructor Tests + @Test + void constructorWithNullAlgorithmShouldThrowException() { + assertThatIllegalArgumentException() + .isThrownBy(() -> new Password4jPasswordEncoder(null)) + .withMessage("algorithm cannot be null"); + } + + @Test + void constructorWithNullHashingFunctionShouldThrowException() { + assertThatIllegalArgumentException() + .isThrownBy(() -> new Password4jPasswordEncoder(null, Password4jPasswordEncoder.Password4jAlgorithm.BCRYPT)) + .withMessage("hashingFunction cannot be null"); + } + + @Test + void constructorWithNullAlgorithmAndValidHashingFunctionShouldThrowException() { + BcryptFunction function = BcryptFunction.getInstance(10); + assertThatIllegalArgumentException() + .isThrownBy(() -> new Password4jPasswordEncoder(function, null)) + .withMessage("algorithm cannot be null"); + } + + @Test + void defaultConstructorShouldUseBCrypt() { + Password4jPasswordEncoder encoder = new Password4jPasswordEncoder(); + assertThat(encoder.getAlgorithm()).isEqualTo(Password4jPasswordEncoder.Password4jAlgorithm.BCRYPT); + assertThat(encoder.getHashingFunction()).isInstanceOf(BcryptFunction.class); + } + + // BCrypt Tests + @Test + void bcryptEncoderShouldEncodeAndVerifyPasswords() { + Password4jPasswordEncoder encoder = Password4jPasswordEncoder.bcrypt(10); + + String encoded = encoder.encode(PASSWORD); + assertThat(encoded) + .isNotNull() + .isNotEqualTo(PASSWORD) + .startsWith("$2b$10$");// Password4j uses $2b$ format + + assertThat(encoder.matches(PASSWORD, encoded)).isTrue(); + assertThat(encoder.matches(WRONG_PASSWORD, encoded)).isFalse(); + assertThat(encoder.matches(null, encoded)).isFalse(); + assertThat(encoder.matches(PASSWORD, null)).isFalse(); + } + + @ParameterizedTest + @ValueSource(ints = {4, 6, 8, 10, 12, 14}) + void bcryptWithDifferentRoundsShouldWork(int rounds) { + Password4jPasswordEncoder encoder = Password4jPasswordEncoder.bcrypt(rounds); + + String encoded = encoder.encode(PASSWORD); + assertThat(encoded).startsWith("$2b$" + String.format("%02d", rounds) + "$"); + assertThat(encoder.matches(PASSWORD, encoded)).isTrue(); + } + + @Test + void bcryptShouldProduceDifferentHashesForSamePassword() { + Password4jPasswordEncoder encoder = Password4jPasswordEncoder.bcrypt(10); + + String hash1 = encoder.encode(PASSWORD); + String hash2 = encoder.encode(PASSWORD); + + assertThat(hash1).isNotEqualTo(hash2); + assertThat(encoder.matches(PASSWORD, hash1)).isTrue(); + assertThat(encoder.matches(PASSWORD, hash2)).isTrue(); + } + + // SCrypt Tests + @Test + void scryptEncoderShouldEncodeAndVerifyPasswords() { + Password4jPasswordEncoder encoder = Password4jPasswordEncoder.scrypt(16384, 8, 1, 32); + + String encoded = encoder.encode(PASSWORD); + assertThat(encoded).isNotNull().isNotEqualTo(PASSWORD); + + assertThat(encoder.matches(PASSWORD, encoded)).isTrue(); + assertThat(encoder.matches(WRONG_PASSWORD, encoded)).isFalse(); + } + + @Test + void scryptWithDifferentParametersShouldWork() { + Password4jPasswordEncoder encoder1 = Password4jPasswordEncoder.scrypt(8192, 8, 1, 32); + Password4jPasswordEncoder encoder2 = Password4jPasswordEncoder.scrypt(16384, 16, 2, 64); + + String hash1 = encoder1.encode(PASSWORD); + String hash2 = encoder2.encode(PASSWORD); + + assertThat(encoder1.matches(PASSWORD, hash1)).isTrue(); + assertThat(encoder2.matches(PASSWORD, hash2)).isTrue(); + assertThat(hash1).isNotEqualTo(hash2); + } + + // Argon2 Tests + @Test + void argon2EncoderShouldEncodeAndVerifyPasswords() { + Password4jPasswordEncoder encoder = Password4jPasswordEncoder.argon2( + 65536, 3, 4, 32, Argon2.ID); + + String encoded = encoder.encode(PASSWORD); + assertThat(encoded) + .isNotNull() + .isNotEqualTo(PASSWORD) + .startsWith("$argon2id$"); + + assertThat(encoder.matches(PASSWORD, encoded)).isTrue(); + assertThat(encoder.matches(WRONG_PASSWORD, encoded)).isFalse(); + } + + @ParameterizedTest + @EnumSource(Argon2.class) + void argon2WithDifferentTypesShouldWork(Argon2 type) { + Password4jPasswordEncoder encoder = Password4jPasswordEncoder.argon2( + 65536, 3, 4, 32, type); + + String encoded = encoder.encode(PASSWORD); + String expectedPrefix = switch (type) { + case D -> "$argon2d$"; + case I -> "$argon2i$"; + case ID -> "$argon2id$"; + }; + + assertThat(encoded).startsWith(expectedPrefix); + assertThat(encoder.matches(PASSWORD, encoded)).isTrue(); + } + + // PBKDF2 Tests + @Test + void pbkdf2EncoderShouldEncodeAndVerifyPasswords() { + Password4jPasswordEncoder encoder = Password4jPasswordEncoder.pbkdf2(100000, 32); + + String encoded = encoder.encode(PASSWORD); + assertThat(encoded) + .isNotNull() + .isNotEqualTo(PASSWORD); + + assertThat(encoder.matches(PASSWORD, encoded)).isTrue(); + assertThat(encoder.matches(WRONG_PASSWORD, encoded)).isFalse(); + } + + @Test + void compressedPbkdf2EncoderShouldEncodeAndVerifyPasswords() { + Password4jPasswordEncoder encoder = Password4jPasswordEncoder.compressedPbkdf2(100000, 32); + + String encoded = encoder.encode(PASSWORD); + assertThat(encoded) + .isNotNull() + .isNotEqualTo(PASSWORD); + + assertThat(encoder.matches(PASSWORD, encoded)).isTrue(); + assertThat(encoder.matches(WRONG_PASSWORD, encoded)).isFalse(); + } + + @ParameterizedTest + @CsvSource({ + "50000, 16", + "100000, 32", + "200000, 64", + "500000, 32" + }) + void pbkdf2WithDifferentParametersShouldWork(int iterations, int keyLength) { + Password4jPasswordEncoder encoder = Password4jPasswordEncoder.pbkdf2(iterations, keyLength); + + String encoded = encoder.encode(PASSWORD); + assertThat(encoder.matches(PASSWORD, encoded)).isTrue(); + } + + // Factory Method Tests + @Test + void defaultsForSpringSecurityShouldUseBCrypt() { + Password4jPasswordEncoder encoder = Password4jPasswordEncoder.defaultsForSpringSecurity(); + + assertThat(encoder.getAlgorithm()).isEqualTo(Password4jPasswordEncoder.Password4jAlgorithm.BCRYPT); + assertThat(encoder.getHashingFunction()).isInstanceOf(BcryptFunction.class); + + String encoded = encoder.encode(PASSWORD); + assertThat(encoded).startsWith("$2b$10$"); // Password4j uses $2b$ format + assertThat(encoder.matches(PASSWORD, encoded)).isTrue(); + } + + // Null and Empty Input Tests + @Test + void encodeNullPasswordShouldReturnNull() { + Password4jPasswordEncoder encoder = new Password4jPasswordEncoder(); + assertThat(encoder.encode(null)).isNull(); + } + + @Test + void encodeEmptyPasswordShouldWork() { + Password4jPasswordEncoder encoder = new Password4jPasswordEncoder(); + String encoded = encoder.encode(""); + assertThat(encoded).isNotNull(); + // AbstractValidatingPasswordEncoder returns false for empty raw passwords + assertThat(encoder.matches("", encoded)).isFalse(); + } + + @Test + void matchesWithNullOrEmptyParametersShouldReturnFalse() { + Password4jPasswordEncoder encoder = new Password4jPasswordEncoder(); + String validHash = encoder.encode(PASSWORD); + + assertThat(encoder.matches(null, validHash)).isFalse(); + assertThat(encoder.matches("", validHash)).isFalse(); + assertThat(encoder.matches(PASSWORD, null)).isFalse(); + assertThat(encoder.matches(PASSWORD, "")).isFalse(); + assertThat(encoder.matches(null, null)).isFalse(); + assertThat(encoder.matches("", "")).isFalse(); + } + + // Password Variety Tests + @ParameterizedTest + @ValueSource(strings = {"password", "123456", "P@ssw0rd!", "a very long password with spaces and symbols !@#$%"}) + void shouldHandleVariousPasswordFormats(String password) { + Password4jPasswordEncoder encoder = Password4jPasswordEncoder.defaultsForSpringSecurity(); + + String encoded = encoder.encode(password); + assertThat(encoded).isNotNull(); + assertThat(encoder.matches(password, encoded)).isTrue(); + assertThat(encoder.matches(password + "x", encoded)).isFalse(); + } + + @Test + void shouldHandleUnicodePasswords() { + Password4jPasswordEncoder encoder = Password4jPasswordEncoder.defaultsForSpringSecurity(); + + String encoded = encoder.encode(UNICODE_PASSWORD); + assertThat(encoded).isNotNull(); + assertThat(encoder.matches(UNICODE_PASSWORD, encoded)).isTrue(); + assertThat(encoder.matches("password", encoded)).isFalse(); + } + + @Test + void shouldHandleLongPasswords() { + Password4jPasswordEncoder encoder = Password4jPasswordEncoder.defaultsForSpringSecurity(); + + String encoded = encoder.encode(LONG_PASSWORD); + assertThat(encoded).isNotNull(); + assertThat(encoder.matches(LONG_PASSWORD, encoded)).isTrue(); + } + + // Upgrade Encoding Tests + @Test + void upgradeEncodingShouldReturnFalse() { + Password4jPasswordEncoder encoder = new Password4jPasswordEncoder(); + String encoded = encoder.encode(PASSWORD); + + // For now, upgradeEncoding should return false + assertThat(encoder.upgradeEncoding(encoded)).isFalse(); + assertThat(encoder.upgradeEncoding(null)).isFalse(); + assertThat(encoder.upgradeEncoding("")).isFalse(); + } + + @ParameterizedTest + @EnumSource(Password4jPasswordEncoder.Password4jAlgorithm.class) + void upgradeEncodingShouldReturnFalseForAllAlgorithms(Password4jPasswordEncoder.Password4jAlgorithm algorithm) { + Password4jPasswordEncoder encoder = new Password4jPasswordEncoder(algorithm); + String encoded = encoder.encode(PASSWORD); + + assertThat(encoder.upgradeEncoding(encoded)).isFalse(); + } + + // Custom Hashing Function Tests + @Test + void shouldWorkWithCustomHashingFunction() { + BcryptFunction customFunction = BcryptFunction.getInstance(12); + Password4jPasswordEncoder encoder = new Password4jPasswordEncoder(customFunction, Password4jPasswordEncoder.Password4jAlgorithm.BCRYPT); + + String encoded = encoder.encode(PASSWORD); + assertThat(encoded).startsWith("$2b$12$"); // Password4j uses $2b$ format + assertThat(encoder.matches(PASSWORD, encoded)).isTrue(); + } + + @Test + void shouldWorkWithCustomScryptFunction() { + ScryptFunction customFunction = ScryptFunction.getInstance(32768, 16, 2, 64); + Password4jPasswordEncoder encoder = new Password4jPasswordEncoder(customFunction, Password4jPasswordEncoder.Password4jAlgorithm.SCRYPT); + + String encoded = encoder.encode(PASSWORD); + assertThat(encoder.matches(PASSWORD, encoded)).isTrue(); + } + + @Test + void shouldWorkWithCustomArgon2Function() { + Argon2Function customFunction = Argon2Function.getInstance(131072, 4, 8, 64, Argon2.ID); + Password4jPasswordEncoder encoder = new Password4jPasswordEncoder(customFunction, Password4jPasswordEncoder.Password4jAlgorithm.ARGON2); + + String encoded = encoder.encode(PASSWORD); + assertThat(encoded).startsWith("$argon2id$"); + assertThat(encoder.matches(PASSWORD, encoded)).isTrue(); + } + + // Algorithm Coverage Tests + @Test + void shouldCreateEncoderForEachAlgorithm() { + // Test all algorithm types can be instantiated + for (Password4jPasswordEncoder.Password4jAlgorithm algorithm : Password4jPasswordEncoder.Password4jAlgorithm.values()) { + Password4jPasswordEncoder encoder = new Password4jPasswordEncoder(algorithm); + assertThat(encoder.getAlgorithm()).isEqualTo(algorithm); + + String encoded = encoder.encode(PASSWORD); + assertThat(encoded).isNotNull(); + assertThat(encoder.matches(PASSWORD, encoded)).isTrue(); + } + } + + @ParameterizedTest + @EnumSource(Password4jPasswordEncoder.Password4jAlgorithm.class) + void allAlgorithmsShouldProduceValidHashes(Password4jPasswordEncoder.Password4jAlgorithm algorithm) { + Password4jPasswordEncoder encoder = new Password4jPasswordEncoder(algorithm); + + String encoded = encoder.encode(PASSWORD); + assertThat(encoded) + .isNotNull() + .isNotEmpty() + .isNotEqualTo(PASSWORD); + + assertThat(encoder.matches(PASSWORD, encoded)).isTrue(); + assertThat(encoder.matches(WRONG_PASSWORD, encoded)).isFalse(); + } + + // Security Properties Tests + @RepeatedTest(10) + void samePasswordShouldProduceDifferentHashes() { + Password4jPasswordEncoder encoder = Password4jPasswordEncoder.defaultsForSpringSecurity(); + + String hash1 = encoder.encode(PASSWORD); + String hash2 = encoder.encode(PASSWORD); + + // Hashes should be different (due to salt) + assertThat(hash1).isNotEqualTo(hash2); + + // But both should verify correctly + assertThat(encoder.matches(PASSWORD, hash1)).isTrue(); + assertThat(encoder.matches(PASSWORD, hash2)).isTrue(); + } + + @Test + void hashLengthShouldBeConsistent() { + Password4jPasswordEncoder encoder = Password4jPasswordEncoder.defaultsForSpringSecurity(); + + String hash1 = encoder.encode("short"); + String hash2 = encoder.encode("this is a much longer password with many characters"); + + // BCrypt hashes should have consistent length + assertThat(hash1).hasSize(60); // BCrypt produces 60-character hashes + assertThat(hash2).hasSize(60); + } + + @Test + void similarPasswordsShouldProduceCompletelyDifferentHashes() { + Password4jPasswordEncoder encoder = Password4jPasswordEncoder.defaultsForSpringSecurity(); + + String hash1 = encoder.encode("password"); + String hash2 = encoder.encode("password1"); + String hash3 = encoder.encode("Password"); + + assertThat(hash1) + .isNotEqualTo(hash2) + .isNotEqualTo(hash3); + assertThat(hash2).isNotEqualTo(hash3); + + // Cross-verification should fail + assertThat(encoder.matches("password", hash2)).isFalse(); + assertThat(encoder.matches("password1", hash1)).isFalse(); + } + + + // Additional Security and Robustness Tests + @Test + void shouldHandleVeryLongPasswords() { + Password4jPasswordEncoder encoder = Password4jPasswordEncoder.defaultsForSpringSecurity(); + String veryLongPassword = "a".repeat(10000); // 10KB password + + String encoded = encoder.encode(veryLongPassword); + assertThat(encoded).isNotNull(); + assertThat(encoder.matches(veryLongPassword, encoded)).isTrue(); + // Fix: BCrypt truncates passwords longer than 72 bytes, so we need to test with a meaningful difference + // Test with a shorter difference that's within the 72-byte limit + String slightlyDifferentPassword = "b" + veryLongPassword.substring(1); // Change first character + assertThat(encoder.matches(slightlyDifferentPassword, encoded)).isFalse(); + } + + @Test + void shouldHandlePasswordsWithNullBytes() { + Password4jPasswordEncoder encoder = Password4jPasswordEncoder.defaultsForSpringSecurity(); + String passwordWithNull = "password\u0000test"; + + String encoded = encoder.encode(passwordWithNull); + assertThat(encoded).isNotNull(); + assertThat(encoder.matches(passwordWithNull, encoded)).isTrue(); + assertThat(encoder.matches("passwordtest", encoded)).isFalse(); + } + + @Test + void shouldProduceStrongRandomness() { + Password4jPasswordEncoder encoder = Password4jPasswordEncoder.defaultsForSpringSecurity(); + java.util.Set hashes = new java.util.HashSet<>(); + + // Generate many hashes of the same password + for (int i = 0; i < 100; i++) { + String hash = encoder.encode(PASSWORD); + assertThat(hashes.add(hash)).isTrue(); // Each hash should be unique + } + + assertThat(hashes).hasSize(100); + } + + @Test + void shouldResistTimingAttacks() { + Password4jPasswordEncoder encoder = Password4jPasswordEncoder.defaultsForSpringSecurity(); + String validHash = encoder.encode(PASSWORD); + + // Measure time for correct password + long startTime = System.nanoTime(); + boolean result1 = encoder.matches(PASSWORD, validHash); + long correctTime = System.nanoTime() - startTime; + + // Measure time for wrong password of same length + startTime = System.nanoTime(); + boolean result2 = encoder.matches("passwore", validHash); // Same length, different content + long wrongTime = System.nanoTime() - startTime; + + assertThat(result1).isTrue(); + assertThat(result2).isFalse(); + + // Times should be relatively close (within 10x factor for timing attack resistance) + double ratio = Math.max(correctTime, wrongTime) / (double) Math.min(correctTime, wrongTime); + assertThat(ratio).isLessThan(10.0); + } + + + @Test + void scryptShouldHandleEdgeCaseParameters() { + // Test with minimum viable parameters + Password4jPasswordEncoder encoder = Password4jPasswordEncoder.scrypt(2, 1, 1, 16); + + String encoded = encoder.encode(PASSWORD); + assertThat(encoder.matches(PASSWORD, encoded)).isTrue(); + } + + @Test + void argon2ShouldWorkWithDifferentMemorySizes() { + // Test with various memory configurations + int[] memorySizes = {1024, 4096, 16384, 65536}; + + for (int memory : memorySizes) { + Password4jPasswordEncoder encoder = Password4jPasswordEncoder.argon2(memory, 2, 1, 32, Argon2.ID); + String encoded = encoder.encode(PASSWORD); + assertThat(encoder.matches(PASSWORD, encoded)).isTrue(); + } + } + + @Test + void pbkdf2ShouldWorkWithDifferentHashAlgorithms() { + // Test that the implementation handles different internal configurations + Password4jPasswordEncoder encoder1 = Password4jPasswordEncoder.pbkdf2(50000, 16); + Password4jPasswordEncoder encoder2 = Password4jPasswordEncoder.pbkdf2(100000, 32); + Password4jPasswordEncoder encoder3 = Password4jPasswordEncoder.pbkdf2(200000, 64); + + String hash1 = encoder1.encode(PASSWORD); + String hash2 = encoder2.encode(PASSWORD); + String hash3 = encoder3.encode(PASSWORD); + + assertThat(encoder1.matches(PASSWORD, hash1)).isTrue(); + assertThat(encoder2.matches(PASSWORD, hash2)).isTrue(); + assertThat(encoder3.matches(PASSWORD, hash3)).isTrue(); + + // Hashes should be different due to different parameters + assertThat(hash1).isNotEqualTo(hash2); + assertThat(hash2).isNotEqualTo(hash3); + } + + // Cross-Algorithm Verification Tests + @Test + void differentAlgorithmsShouldNotCrossVerify() { + Password4jPasswordEncoder bcryptEncoder = Password4jPasswordEncoder.bcrypt(10); + Password4jPasswordEncoder scryptEncoder = Password4jPasswordEncoder.scrypt(16384, 8, 1, 32); + Password4jPasswordEncoder argon2Encoder = Password4jPasswordEncoder.argon2(65536, 3, 4, 32, Argon2.ID); + + String bcryptHash = bcryptEncoder.encode(PASSWORD); + String scryptHash = scryptEncoder.encode(PASSWORD); + String argon2Hash = argon2Encoder.encode(PASSWORD); + + // Each encoder should only verify its own hashes + assertThat(bcryptEncoder.matches(PASSWORD, bcryptHash)).isTrue(); + assertThat(bcryptEncoder.matches(PASSWORD, scryptHash)).isFalse(); + assertThat(bcryptEncoder.matches(PASSWORD, argon2Hash)).isFalse(); + + assertThat(scryptEncoder.matches(PASSWORD, scryptHash)).isTrue(); + assertThat(scryptEncoder.matches(PASSWORD, bcryptHash)).isFalse(); + assertThat(scryptEncoder.matches(PASSWORD, argon2Hash)).isFalse(); + + assertThat(argon2Encoder.matches(PASSWORD, argon2Hash)).isTrue(); + assertThat(argon2Encoder.matches(PASSWORD, bcryptHash)).isFalse(); + assertThat(argon2Encoder.matches(PASSWORD, scryptHash)).isFalse(); + } + + + @Test + void encodingShouldCompleteInReasonableTime() { + Password4jPasswordEncoder encoder = Password4jPasswordEncoder.defaultsForSpringSecurity(); + + long startTime = System.currentTimeMillis(); + String encoded = encoder.encode(PASSWORD); + long duration = System.currentTimeMillis() - startTime; + + assertThat(encoded).isNotNull(); + assertThat(duration).isLessThan(5000); // Should complete within 5 seconds + } + + // Compatibility and Integration Tests + @Test + void shouldBeCompatibleWithSpringSecurityConventions() { + Password4jPasswordEncoder encoder = Password4jPasswordEncoder.defaultsForSpringSecurity(); + + // Test common Spring Security patterns + assertThat(encoder.encode(null)).isNull(); + assertThat(encoder.matches(null, "hash")).isFalse(); + assertThat(encoder.matches("password", null)).isFalse(); + assertThat(encoder.upgradeEncoding("anyhash")).isFalse(); + + // Test that it follows AbstractValidatingPasswordEncoder contract + assertThat(encoder.matches("", "")).isFalse(); + assertThat(encoder.upgradeEncoding("")).isFalse(); + } + + @Test + void factoryMethodsShouldCreateCorrectInstances() { + // Verify all factory methods create properly configured instances + Password4jPasswordEncoder bcrypt = Password4jPasswordEncoder.bcrypt(12); + assertThat(bcrypt.getAlgorithm()).isEqualTo(Password4jPasswordEncoder.Password4jAlgorithm.BCRYPT); + assertThat(bcrypt.getHashingFunction()).isInstanceOf(BcryptFunction.class); + + Password4jPasswordEncoder scrypt = Password4jPasswordEncoder.scrypt(32768, 8, 1, 32); + assertThat(scrypt.getAlgorithm()).isEqualTo(Password4jPasswordEncoder.Password4jAlgorithm.SCRYPT); + assertThat(scrypt.getHashingFunction()).isInstanceOf(ScryptFunction.class); + + Password4jPasswordEncoder argon2 = Password4jPasswordEncoder.argon2(65536, 3, 4, 32, Argon2.ID); + assertThat(argon2.getAlgorithm()).isEqualTo(Password4jPasswordEncoder.Password4jAlgorithm.ARGON2); + assertThat(argon2.getHashingFunction()).isInstanceOf(Argon2Function.class); + + Password4jPasswordEncoder pbkdf2 = Password4jPasswordEncoder.pbkdf2(100000, 32); + assertThat(pbkdf2.getAlgorithm()).isEqualTo(Password4jPasswordEncoder.Password4jAlgorithm.PBKDF2); + assertThat(pbkdf2.getHashingFunction()).isInstanceOf(CompressedPBKDF2Function.class); + + Password4jPasswordEncoder compressedPbkdf2 = Password4jPasswordEncoder.compressedPbkdf2(100000, 32); + assertThat(compressedPbkdf2.getAlgorithm()).isEqualTo(Password4jPasswordEncoder.Password4jAlgorithm.COMPRESSED_PBKDF2); + assertThat(compressedPbkdf2.getHashingFunction()).isInstanceOf(CompressedPBKDF2Function.class); + } +} 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..44248aff442 --- /dev/null +++ b/crypto/src/test/java/org/springframework/security/crypto/password4j/PasswordCompatibilityTests.java @@ -0,0 +1,160 @@ +/* + * 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.BcryptFunction; +import com.password4j.CompressedPBKDF2Function; +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 + * {@link Password4jPasswordEncoder}. + * + * @author Mehrdad Bozorgmehr + */ +class PasswordCompatibilityTests { + + private static final String PASSWORD = "password"; + + // BCrypt Compatibility Tests + @Test + void bcryptEncodedWithSpringSecurityShouldMatchWithPassword4j() { + BCryptPasswordEncoder springEncoder = new BCryptPasswordEncoder(10); + Password4jPasswordEncoder password4jEncoder = new Password4jPasswordEncoder(BcryptFunction.getInstance(10)); + + String encodedBySpring = springEncoder.encode(PASSWORD); + boolean matchedByPassword4j = password4jEncoder.matches(PASSWORD, encodedBySpring); + + assertThat(matchedByPassword4j).isTrue(); + } + + @Test + void bcryptEncodedWithPassword4jShouldMatchWithSpringSecurity() { + BCryptPasswordEncoder springEncoder = new BCryptPasswordEncoder(10); + Password4jPasswordEncoder password4jEncoder = new Password4jPasswordEncoder(BcryptFunction.getInstance(10)); + + 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); + Password4jPasswordEncoder password4jEncoder = new Password4jPasswordEncoder( + ScryptFunction.getInstance(16384, 8, 1, 32)); + + String encodedBySpring = springEncoder.encode(PASSWORD); + boolean matchedByPassword4j = password4jEncoder.matches(PASSWORD, encodedBySpring); + + assertThat(matchedByPassword4j).isTrue(); + } + + @Test + void scryptEncodedWithPassword4jShouldMatchWithSpringSecurity() { + SCryptPasswordEncoder springEncoder = new SCryptPasswordEncoder(16384, 8, 1, 32, 64); + Password4jPasswordEncoder password4jEncoder = new Password4jPasswordEncoder( + ScryptFunction.getInstance(16384, 8, 1, 32)); + + 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, 65536, 3); + Password4jPasswordEncoder password4jEncoder = new Password4jPasswordEncoder( + Argon2Function.getInstance(65536, 3, 1, 32, Argon2.ID)); + + String encodedBySpring = springEncoder.encode(PASSWORD); + boolean matchedByPassword4j = password4jEncoder.matches(PASSWORD, encodedBySpring); + + assertThat(matchedByPassword4j).isTrue(); + } + + @Test + void argon2EncodedWithPassword4jShouldMatchWithSpringSecurity() { + Argon2PasswordEncoder springEncoder = new Argon2PasswordEncoder(16, 32, 1, 65536, 3); + Password4jPasswordEncoder password4jEncoder = new Password4jPasswordEncoder( + Argon2Function.getInstance(65536, 3, 1, 32, Argon2.ID)); + + String encodedByPassword4j = password4jEncoder.encode(PASSWORD); + boolean matchedBySpring = springEncoder.matches(PASSWORD, encodedByPassword4j); + + assertThat(matchedBySpring).isTrue(); + } + + // PBKDF2 Compatibility Tests - Note: Different format implementations + @Test + void pbkdf2BasicFunctionalityTest() { + // Test that both encoders work independently with their own formats + // Spring Security PBKDF2 + Pbkdf2PasswordEncoder springEncoder = new Pbkdf2PasswordEncoder("", 16, 100000, + Pbkdf2PasswordEncoder.SecretKeyFactoryAlgorithm.PBKDF2WithHmacSHA256); + String springEncoded = springEncoder.encode(PASSWORD); + assertThat(springEncoder.matches(PASSWORD, springEncoded)).isTrue(); + + // Password4j PBKDF2 + Password4jPasswordEncoder password4jEncoder = new Password4jPasswordEncoder( + CompressedPBKDF2Function.getInstance("SHA256", 100000, 32)); + String password4jEncoded = password4jEncoder.encode(PASSWORD); + assertThat(password4jEncoder.matches(PASSWORD, password4jEncoded)).isTrue(); + + // Note: These encoders use different hash formats and are not cross-compatible + // This is expected behavior due to different implementation standards + } + + // Cross-Algorithm Tests (should fail) + @Test + void bcryptEncodedPasswordShouldNotMatchArgon2Encoder() { + Password4jPasswordEncoder bcryptEncoder = new Password4jPasswordEncoder(BcryptFunction.getInstance(10)); + Password4jPasswordEncoder argon2Encoder = new Password4jPasswordEncoder(AlgorithmFinder.getArgon2Instance()); + + String bcryptEncoded = bcryptEncoder.encode(PASSWORD); + boolean matchedByArgon2 = argon2Encoder.matches(PASSWORD, bcryptEncoded); + + assertThat(matchedByArgon2).isFalse(); + } + + @Test + void argon2EncodedPasswordShouldNotMatchScryptEncoder() { + Password4jPasswordEncoder argon2Encoder = new Password4jPasswordEncoder(AlgorithmFinder.getArgon2Instance()); + Password4jPasswordEncoder scryptEncoder = new Password4jPasswordEncoder(AlgorithmFinder.getScryptInstance()); + + String argon2Encoded = argon2Encoder.encode(PASSWORD); + boolean matchedByScrypt = scryptEncoder.matches(PASSWORD, argon2Encoded); + + assertThat(matchedByScrypt).isFalse(); + } + +} 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 } } - From 9f5d27e8d05bb920234627b5664732acafa6e8f8 Mon Sep 17 00:00:00 2001 From: "M.Bozorgmehr" Date: Sun, 7 Sep 2025 15:34:37 +0330 Subject: [PATCH 2/9] Refactor Password4jPasswordEncoder to use AlgorithmFinder for algorithm selection and enhance documentation Closes gh-17706 Signed-off-by: M.Bozorgmehr Signed-off-by: Mehrdad Signed-off-by: M.Bozorgmehr --- .../factory/PasswordEncoderFactories.java | 13 - .../password4j/Password4jPasswordEncoder.java | 247 ++----- .../crypto/password4j/package-info.java | 1 - .../Password4jPasswordEncoderTests.java | 669 +++--------------- gradle/libs.versions.toml | 2 + 5 files changed, 177 insertions(+), 755 deletions(-) diff --git a/crypto/src/main/java/org/springframework/security/crypto/factory/PasswordEncoderFactories.java b/crypto/src/main/java/org/springframework/security/crypto/factory/PasswordEncoderFactories.java index 7704f6f2101..b6c6f2f3e52 100644 --- a/crypto/src/main/java/org/springframework/security/crypto/factory/PasswordEncoderFactories.java +++ b/crypto/src/main/java/org/springframework/security/crypto/factory/PasswordEncoderFactories.java @@ -24,7 +24,6 @@ import org.springframework.security.crypto.password.DelegatingPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.crypto.password.Pbkdf2PasswordEncoder; -import org.springframework.security.crypto.password4j.Password4jPasswordEncoder; import org.springframework.security.crypto.scrypt.SCryptPasswordEncoder; /** @@ -66,10 +65,6 @@ private PasswordEncoderFactories() { *
  • argon2 - {@link Argon2PasswordEncoder#defaultsForSpringSecurity_v5_2()}
  • *
  • argon2@SpringSecurity_v5_8 - * {@link Argon2PasswordEncoder#defaultsForSpringSecurity_v5_8()}
  • - *
  • password4j-bcrypt - {@link Password4jPasswordEncoder} with BCrypt
  • - *
  • password4j-scrypt - {@link Password4jPasswordEncoder} with SCrypt
  • - *
  • password4j-argon2 - {@link Password4jPasswordEncoder} with Argon2
  • - *
  • password4j-pbkdf2 - {@link Password4jPasswordEncoder} with PBKDF2
  • * * @return the {@link PasswordEncoder} to use */ @@ -92,14 +87,6 @@ public static PasswordEncoder createDelegatingPasswordEncoder() { encoders.put("sha256", new org.springframework.security.crypto.password.StandardPasswordEncoder()); encoders.put("argon2", Argon2PasswordEncoder.defaultsForSpringSecurity_v5_2()); encoders.put("argon2@SpringSecurity_v5_8", Argon2PasswordEncoder.defaultsForSpringSecurity_v5_8()); - - // Password4j implementations - encoders.put("password4j-bcrypt", Password4jPasswordEncoder.bcrypt(10)); - encoders.put("password4j-scrypt", Password4jPasswordEncoder.scrypt(16384, 8, 1, 32)); - encoders.put("password4j-argon2", Password4jPasswordEncoder.argon2(65536, 3, 4, 32, - com.password4j.types.Argon2.ID)); - encoders.put("password4j-pbkdf2", Password4jPasswordEncoder.pbkdf2(310000, 32)); - return new DelegatingPasswordEncoder(encodingId, encoders); } 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 index 402fb4d42b7..36d286584fa 100644 --- a/crypto/src/main/java/org/springframework/security/crypto/password4j/Password4jPasswordEncoder.java +++ b/crypto/src/main/java/org/springframework/security/crypto/password4j/Password4jPasswordEncoder.java @@ -16,24 +16,54 @@ package org.springframework.security.crypto.password4j; -import com.password4j.*; -import com.password4j.types.Argon2; +import com.password4j.AlgorithmFinder; +import com.password4j.Hash; +import com.password4j.HashingFunction; +import com.password4j.Password; import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; + import org.springframework.security.crypto.password.AbstractValidatingPasswordEncoder; import org.springframework.util.Assert; /** - * Implementation of {@link org.springframework.security.crypto.password.PasswordEncoder} that uses the Password4j library. - * This encoder supports multiple password hashing algorithms including BCrypt, SCrypt, Argon2, and PBKDF2. + * Implementation of {@link org.springframework.security.crypto.password.PasswordEncoder} + * that uses the Password4j library. This encoder supports multiple password hashing + * algorithms including BCrypt, SCrypt, Argon2, and PBKDF2. + * + *

    + * The encoder uses the provided {@link HashingFunction} for both encoding and + * verification. Password4j can automatically detect the algorithm used in existing hashes + * during verification. + *

    * - *

    The encoder determines the algorithm used based on the algorithm type specified during construction. - * For verification, it can automatically detect the algorithm used in existing hashes.

    + *

    + * This implementation is thread-safe and can be shared across multiple threads. + *

    * - *

    This implementation is thread-safe and can be shared across multiple threads.

    + *

    + * Usage Examples: + *

    + *
    {@code
    + * // Using default algorithms from AlgorithmFinder (recommended approach)
    + * PasswordEncoder bcryptEncoder = new Password4jPasswordEncoder(AlgorithmFinder.getBcryptInstance());
    + * PasswordEncoder argon2Encoder = new Password4jPasswordEncoder(AlgorithmFinder.getArgon2Instance());
    + * PasswordEncoder scryptEncoder = new Password4jPasswordEncoder(AlgorithmFinder.getScryptInstance());
    + * PasswordEncoder pbkdf2Encoder = new Password4jPasswordEncoder(AlgorithmFinder.getPBKDF2Instance());
    + *
    + * // Using customized algorithm parameters
    + * PasswordEncoder customBcrypt = new Password4jPasswordEncoder(BcryptFunction.getInstance(12));
    + * PasswordEncoder customArgon2 = new Password4jPasswordEncoder(
    + *     Argon2Function.getInstance(65536, 3, 4, 32, Argon2.ID));
    + * PasswordEncoder customScrypt = new Password4jPasswordEncoder(
    + *     ScryptFunction.getInstance(32768, 8, 1, 32));
    + * PasswordEncoder customPbkdf2 = new Password4jPasswordEncoder(
    + *     CompressedPBKDF2Function.getInstance("SHA256", 310000, 32));
    + * }
    * * @author Mehrdad Bozorgmehr - * @since 6.5 + * @since 7.0 + * @see AlgorithmFinder */ public class Password4jPasswordEncoder extends AbstractValidatingPasswordEncoder { @@ -41,146 +71,38 @@ public class Password4jPasswordEncoder extends AbstractValidatingPasswordEncoder private final HashingFunction hashingFunction; - private final Password4jAlgorithm algorithm; - - - /** - * Enumeration of supported Password4j algorithms. - */ - public enum Password4jAlgorithm { - /** - * BCrypt algorithm. - */ - BCRYPT, - /** - * SCrypt algorithm. - */ - SCRYPT, - /** - * Argon2 algorithm. - */ - ARGON2, - /** - * PBKDF2 algorithm. - */ - PBKDF2, - /** - * Compressed PBKDF2 algorithm. - */ - COMPRESSED_PBKDF2 - } - - /** - * Constructs a Password4j password encoder with the default BCrypt algorithm. - */ - public Password4jPasswordEncoder() { - this(Password4jAlgorithm.BCRYPT); - } - /** - * Constructs a Password4j password encoder with the specified algorithm using default parameters. + * Constructs a Password4j password encoder with the specified hashing function. * - * @param algorithm the password hashing algorithm to use - */ - public Password4jPasswordEncoder(Password4jAlgorithm algorithm) { - Assert.notNull(algorithm, "algorithm cannot be null"); - this.algorithm = algorithm; - this.hashingFunction = createDefaultHashingFunction(algorithm); - } - - /** - * Constructs a Password4j password encoder with a custom hashing function. + *

    + * It is recommended to use password4j's {@link AlgorithmFinder} to obtain default + * instances with secure configurations: + *

    + *
      + *
    • {@code AlgorithmFinder.getBcryptInstance()} - BCrypt with default settings
    • + *
    • {@code AlgorithmFinder.getArgon2Instance()} - Argon2 with default settings
    • + *
    • {@code AlgorithmFinder.getScryptInstance()} - SCrypt with default settings
    • + *
    • {@code AlgorithmFinder.getPBKDF2Instance()} - PBKDF2 with default settings
    • + *
    * - * @param hashingFunction the custom hashing function to use - * @param algorithm the password hashing algorithm type + *

    + * For custom configurations, you can create specific function instances: + *

    + *
      + *
    • {@code BcryptFunction.getInstance(12)} - BCrypt with 12 rounds
    • + *
    • {@code Argon2Function.getInstance(65536, 3, 4, 32, Argon2.ID)} - Custom + * Argon2
    • + *
    • {@code ScryptFunction.getInstance(16384, 8, 1, 32)} - Custom SCrypt
    • + *
    • {@code CompressedPBKDF2Function.getInstance("SHA256", 310000, 32)} - Custom + * PBKDF2
    • + *
    + * @param hashingFunction the hashing function to use for encoding passwords, must not + * be null + * @throws IllegalArgumentException if hashingFunction is null */ - public Password4jPasswordEncoder(HashingFunction hashingFunction, Password4jAlgorithm algorithm) { + public Password4jPasswordEncoder(HashingFunction hashingFunction) { Assert.notNull(hashingFunction, "hashingFunction cannot be null"); - Assert.notNull(algorithm, "algorithm cannot be null"); this.hashingFunction = hashingFunction; - this.algorithm = algorithm; - } - - /** - * Creates a Password4j password encoder with BCrypt algorithm and specified rounds. - * - * @param rounds the number of rounds (cost factor) for BCrypt - * @return a new Password4j password encoder - */ - public static Password4jPasswordEncoder bcrypt(int rounds) { - return new Password4jPasswordEncoder(BcryptFunction.getInstance(rounds), Password4jAlgorithm.BCRYPT); - } - - /** - * Creates a Password4j password encoder with SCrypt algorithm and specified parameters. - * - * @param workFactor the work factor (N parameter) - * @param resources the resources (r parameter) - * @param parallelization the parallelization (p parameter) - * @param derivedKeyLength the derived key length - * @return a new Password4j password encoder - */ - public static Password4jPasswordEncoder scrypt(int workFactor, int resources, int parallelization, int derivedKeyLength) { - return new Password4jPasswordEncoder( - ScryptFunction.getInstance(workFactor, resources, parallelization, derivedKeyLength), - Password4jAlgorithm.SCRYPT - ); - } - - /** - * Creates a Password4j password encoder with Argon2 algorithm and specified parameters. - * - * @param memory the memory cost - * @param iterations the number of iterations - * @param parallelism the parallelism - * @param outputLength the output length - * @param type the Argon2 type - * @return a new Password4j password encoder - */ - public static Password4jPasswordEncoder argon2(int memory, int iterations, int parallelism, int outputLength, Argon2 type) { - return new Password4jPasswordEncoder( - Argon2Function.getInstance(memory, iterations, parallelism, outputLength, type), - Password4jAlgorithm.ARGON2 - ); - } - - /** - * Creates a Password4j password encoder with PBKDF2 algorithm and specified parameters. - * - * @param iterations the number of iterations - * @param derivedKeyLength the derived key length - * @return a new Password4j password encoder - */ - public static Password4jPasswordEncoder pbkdf2(int iterations, int derivedKeyLength) { - return new Password4jPasswordEncoder( - CompressedPBKDF2Function.getInstance("SHA256", iterations, derivedKeyLength), - Password4jAlgorithm.PBKDF2 - ); - } - - /** - * Creates a Password4j password encoder with compressed PBKDF2 algorithm. - * - * @param iterations the number of iterations - * @param derivedKeyLength the derived key length - * @return a new Password4j password encoder - */ - public static Password4jPasswordEncoder compressedPbkdf2(int iterations, int derivedKeyLength) { - return new Password4jPasswordEncoder( - CompressedPBKDF2Function.getInstance("SHA256", iterations, derivedKeyLength), - Password4jAlgorithm.COMPRESSED_PBKDF2 - ); - } - - /** - * Creates a Password4j password encoder with default settings for Spring Security v5.8+. - * This uses BCrypt with 10 rounds. - * - * @return a new Password4j password encoder with recommended defaults - * @since 6.5 - */ - public static Password4jPasswordEncoder defaultsForSpringSecurity() { - return bcrypt(10); } @Override @@ -188,7 +110,8 @@ protected String encodeNonNullPassword(String rawPassword) { try { Hash hash = Password.hash(rawPassword).with(this.hashingFunction); return hash.getResult(); - } catch (Exception ex) { + } + catch (Exception ex) { throw new IllegalStateException("Failed to encode password using Password4j", ex); } } @@ -198,7 +121,8 @@ protected boolean matchesNonNull(String rawPassword, String encodedPassword) { try { // Use the specific hashing function for verification return Password.check(rawPassword, encodedPassword).with(this.hashingFunction); - } catch (Exception ex) { + } + catch (Exception ex) { this.logger.warn("Password verification failed for encoded password: " + encodedPassword, ex); return false; } @@ -211,39 +135,4 @@ protected boolean upgradeEncodingNonNull(String encodedPassword) { return false; } - /** - * Creates a default hashing function for the specified algorithm. - * - * @param algorithm the password hashing algorithm - * @return the default hashing function - */ - private static HashingFunction createDefaultHashingFunction(Password4jAlgorithm algorithm) { - return switch (algorithm) { - case BCRYPT -> BcryptFunction.getInstance(10); // Default 10 rounds - case SCRYPT -> ScryptFunction.getInstance(16384, 8, 1, 32); // Default parameters - case ARGON2 -> Argon2Function.getInstance(65536, 3, 4, 32, Argon2.ID); // Default parameters - case PBKDF2 -> - CompressedPBKDF2Function.getInstance("SHA256", 310000, 32); // Use compressed format for self-contained encoding - case COMPRESSED_PBKDF2 -> CompressedPBKDF2Function.getInstance("SHA256", 310000, 32); - }; - } - - /** - * Gets the algorithm used by this encoder. - * - * @return the password hashing algorithm - */ - public Password4jAlgorithm getAlgorithm() { - return this.algorithm; - } - - /** - * Gets the hashing function used by this encoder. - * - * @return the hashing function - */ - public HashingFunction getHashingFunction() { - return this.hashingFunction; - } - } 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 index f15bf9e10b0..7310e80b8fd 100644 --- 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 @@ -14,7 +14,6 @@ * limitations under the License. */ - @NullMarked package org.springframework.security.crypto.password4j; 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 index 93c6e90f451..f4bec5dd4a6 100644 --- a/crypto/src/test/java/org/springframework/security/crypto/password4j/Password4jPasswordEncoderTests.java +++ b/crypto/src/test/java/org/springframework/security/crypto/password4j/Password4jPasswordEncoderTests.java @@ -16,17 +16,10 @@ package org.springframework.security.crypto.password4j; -import com.password4j.Argon2Function; +import com.password4j.AlgorithmFinder; import com.password4j.BcryptFunction; -import com.password4j.CompressedPBKDF2Function; -import com.password4j.ScryptFunction; -import com.password4j.types.Argon2; -import org.junit.jupiter.api.RepeatedTest; +import com.password4j.HashingFunction; import org.junit.jupiter.api.Test; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.CsvSource; -import org.junit.jupiter.params.provider.EnumSource; -import org.junit.jupiter.params.provider.ValueSource; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; @@ -38,559 +31,111 @@ */ class Password4jPasswordEncoderTests { - private static final String PASSWORD = "password"; - private static final String WRONG_PASSWORD = "wrongpassword"; - private static final String UNICODE_PASSWORD = "пароль123🔐"; - private static final String LONG_PASSWORD = "a".repeat(1000); + private static final String PASSWORD = "password"; - // Constructor Tests - @Test - void constructorWithNullAlgorithmShouldThrowException() { - assertThatIllegalArgumentException() - .isThrownBy(() -> new Password4jPasswordEncoder(null)) - .withMessage("algorithm cannot be null"); - } + private static final String WRONG_PASSWORD = "wrongpassword"; + + // Constructor Tests + @Test + void constructorWithNullHashingFunctionShouldThrowException() { + assertThatIllegalArgumentException().isThrownBy(() -> new Password4jPasswordEncoder(null)) + .withMessage("hashingFunction cannot be null"); + } + + @Test + void constructorWithValidHashingFunctionShouldWork() { + HashingFunction hashingFunction = BcryptFunction.getInstance(10); + Password4jPasswordEncoder encoder = new Password4jPasswordEncoder(hashingFunction); + assertThat(encoder).isNotNull(); + } + + // Basic functionality tests with real HashingFunction instances + @Test + void encodeShouldReturnNonNullHashedPassword() { + HashingFunction hashingFunction = BcryptFunction.getInstance(4); // Use low cost + // for faster + // tests + Password4jPasswordEncoder encoder = new Password4jPasswordEncoder(hashingFunction); + + String result = encoder.encode(PASSWORD); + + assertThat(result).isNotNull().isNotEqualTo(PASSWORD); + } + + @Test + void matchesShouldReturnTrueForValidPassword() { + HashingFunction hashingFunction = BcryptFunction.getInstance(4); // Use low cost + // for faster + // tests + Password4jPasswordEncoder encoder = new Password4jPasswordEncoder(hashingFunction); + + String encoded = encoder.encode(PASSWORD); + boolean result = encoder.matches(PASSWORD, encoded); + + assertThat(result).isTrue(); + } + + @Test + void matchesShouldReturnFalseForInvalidPassword() { + HashingFunction hashingFunction = BcryptFunction.getInstance(4); // Use low cost + // for faster + // tests + Password4jPasswordEncoder encoder = new Password4jPasswordEncoder(hashingFunction); + + String encoded = encoder.encode(PASSWORD); + boolean result = encoder.matches(WRONG_PASSWORD, encoded); + + assertThat(result).isFalse(); + } + + @Test + void matchesShouldReturnFalseForMalformedHash() { + HashingFunction hashingFunction = BcryptFunction.getInstance(4); + Password4jPasswordEncoder encoder = new Password4jPasswordEncoder(hashingFunction); + + // Test with malformed hash that should cause Password4j to throw an exception + boolean result = encoder.matches(PASSWORD, "invalid-hash-format"); + + assertThat(result).isFalse(); + } + + @Test + void upgradeEncodingShouldReturnFalse() { + HashingFunction hashingFunction = BcryptFunction.getInstance(4); + Password4jPasswordEncoder encoder = new Password4jPasswordEncoder(hashingFunction); + + String encoded = encoder.encode(PASSWORD); + boolean result = encoder.upgradeEncoding(encoded); + + assertThat(result).isFalse(); + } + + // AlgorithmFinder Sanity Check Tests + @Test + void algorithmFinderBcryptSanityCheck() { + Password4jPasswordEncoder encoder = new Password4jPasswordEncoder(AlgorithmFinder.getBcryptInstance()); + + String encoded = encoder.encode(PASSWORD); + assertThat(encoder.matches(PASSWORD, encoded)).isTrue(); + assertThat(encoder.matches(WRONG_PASSWORD, encoded)).isFalse(); + } + + @Test + void algorithmFinderArgon2SanityCheck() { + Password4jPasswordEncoder encoder = new Password4jPasswordEncoder(AlgorithmFinder.getArgon2Instance()); + + String encoded = encoder.encode(PASSWORD); + assertThat(encoder.matches(PASSWORD, encoded)).isTrue(); + assertThat(encoder.matches(WRONG_PASSWORD, encoded)).isFalse(); + } + + @Test + void algorithmFinderScryptSanityCheck() { + Password4jPasswordEncoder encoder = new Password4jPasswordEncoder(AlgorithmFinder.getScryptInstance()); + + String encoded = encoder.encode(PASSWORD); + assertThat(encoder.matches(PASSWORD, encoded)).isTrue(); + assertThat(encoder.matches(WRONG_PASSWORD, encoded)).isFalse(); + } - @Test - void constructorWithNullHashingFunctionShouldThrowException() { - assertThatIllegalArgumentException() - .isThrownBy(() -> new Password4jPasswordEncoder(null, Password4jPasswordEncoder.Password4jAlgorithm.BCRYPT)) - .withMessage("hashingFunction cannot be null"); - } - - @Test - void constructorWithNullAlgorithmAndValidHashingFunctionShouldThrowException() { - BcryptFunction function = BcryptFunction.getInstance(10); - assertThatIllegalArgumentException() - .isThrownBy(() -> new Password4jPasswordEncoder(function, null)) - .withMessage("algorithm cannot be null"); - } - - @Test - void defaultConstructorShouldUseBCrypt() { - Password4jPasswordEncoder encoder = new Password4jPasswordEncoder(); - assertThat(encoder.getAlgorithm()).isEqualTo(Password4jPasswordEncoder.Password4jAlgorithm.BCRYPT); - assertThat(encoder.getHashingFunction()).isInstanceOf(BcryptFunction.class); - } - - // BCrypt Tests - @Test - void bcryptEncoderShouldEncodeAndVerifyPasswords() { - Password4jPasswordEncoder encoder = Password4jPasswordEncoder.bcrypt(10); - - String encoded = encoder.encode(PASSWORD); - assertThat(encoded) - .isNotNull() - .isNotEqualTo(PASSWORD) - .startsWith("$2b$10$");// Password4j uses $2b$ format - - assertThat(encoder.matches(PASSWORD, encoded)).isTrue(); - assertThat(encoder.matches(WRONG_PASSWORD, encoded)).isFalse(); - assertThat(encoder.matches(null, encoded)).isFalse(); - assertThat(encoder.matches(PASSWORD, null)).isFalse(); - } - - @ParameterizedTest - @ValueSource(ints = {4, 6, 8, 10, 12, 14}) - void bcryptWithDifferentRoundsShouldWork(int rounds) { - Password4jPasswordEncoder encoder = Password4jPasswordEncoder.bcrypt(rounds); - - String encoded = encoder.encode(PASSWORD); - assertThat(encoded).startsWith("$2b$" + String.format("%02d", rounds) + "$"); - assertThat(encoder.matches(PASSWORD, encoded)).isTrue(); - } - - @Test - void bcryptShouldProduceDifferentHashesForSamePassword() { - Password4jPasswordEncoder encoder = Password4jPasswordEncoder.bcrypt(10); - - String hash1 = encoder.encode(PASSWORD); - String hash2 = encoder.encode(PASSWORD); - - assertThat(hash1).isNotEqualTo(hash2); - assertThat(encoder.matches(PASSWORD, hash1)).isTrue(); - assertThat(encoder.matches(PASSWORD, hash2)).isTrue(); - } - - // SCrypt Tests - @Test - void scryptEncoderShouldEncodeAndVerifyPasswords() { - Password4jPasswordEncoder encoder = Password4jPasswordEncoder.scrypt(16384, 8, 1, 32); - - String encoded = encoder.encode(PASSWORD); - assertThat(encoded).isNotNull().isNotEqualTo(PASSWORD); - - assertThat(encoder.matches(PASSWORD, encoded)).isTrue(); - assertThat(encoder.matches(WRONG_PASSWORD, encoded)).isFalse(); - } - - @Test - void scryptWithDifferentParametersShouldWork() { - Password4jPasswordEncoder encoder1 = Password4jPasswordEncoder.scrypt(8192, 8, 1, 32); - Password4jPasswordEncoder encoder2 = Password4jPasswordEncoder.scrypt(16384, 16, 2, 64); - - String hash1 = encoder1.encode(PASSWORD); - String hash2 = encoder2.encode(PASSWORD); - - assertThat(encoder1.matches(PASSWORD, hash1)).isTrue(); - assertThat(encoder2.matches(PASSWORD, hash2)).isTrue(); - assertThat(hash1).isNotEqualTo(hash2); - } - - // Argon2 Tests - @Test - void argon2EncoderShouldEncodeAndVerifyPasswords() { - Password4jPasswordEncoder encoder = Password4jPasswordEncoder.argon2( - 65536, 3, 4, 32, Argon2.ID); - - String encoded = encoder.encode(PASSWORD); - assertThat(encoded) - .isNotNull() - .isNotEqualTo(PASSWORD) - .startsWith("$argon2id$"); - - assertThat(encoder.matches(PASSWORD, encoded)).isTrue(); - assertThat(encoder.matches(WRONG_PASSWORD, encoded)).isFalse(); - } - - @ParameterizedTest - @EnumSource(Argon2.class) - void argon2WithDifferentTypesShouldWork(Argon2 type) { - Password4jPasswordEncoder encoder = Password4jPasswordEncoder.argon2( - 65536, 3, 4, 32, type); - - String encoded = encoder.encode(PASSWORD); - String expectedPrefix = switch (type) { - case D -> "$argon2d$"; - case I -> "$argon2i$"; - case ID -> "$argon2id$"; - }; - - assertThat(encoded).startsWith(expectedPrefix); - assertThat(encoder.matches(PASSWORD, encoded)).isTrue(); - } - - // PBKDF2 Tests - @Test - void pbkdf2EncoderShouldEncodeAndVerifyPasswords() { - Password4jPasswordEncoder encoder = Password4jPasswordEncoder.pbkdf2(100000, 32); - - String encoded = encoder.encode(PASSWORD); - assertThat(encoded) - .isNotNull() - .isNotEqualTo(PASSWORD); - - assertThat(encoder.matches(PASSWORD, encoded)).isTrue(); - assertThat(encoder.matches(WRONG_PASSWORD, encoded)).isFalse(); - } - - @Test - void compressedPbkdf2EncoderShouldEncodeAndVerifyPasswords() { - Password4jPasswordEncoder encoder = Password4jPasswordEncoder.compressedPbkdf2(100000, 32); - - String encoded = encoder.encode(PASSWORD); - assertThat(encoded) - .isNotNull() - .isNotEqualTo(PASSWORD); - - assertThat(encoder.matches(PASSWORD, encoded)).isTrue(); - assertThat(encoder.matches(WRONG_PASSWORD, encoded)).isFalse(); - } - - @ParameterizedTest - @CsvSource({ - "50000, 16", - "100000, 32", - "200000, 64", - "500000, 32" - }) - void pbkdf2WithDifferentParametersShouldWork(int iterations, int keyLength) { - Password4jPasswordEncoder encoder = Password4jPasswordEncoder.pbkdf2(iterations, keyLength); - - String encoded = encoder.encode(PASSWORD); - assertThat(encoder.matches(PASSWORD, encoded)).isTrue(); - } - - // Factory Method Tests - @Test - void defaultsForSpringSecurityShouldUseBCrypt() { - Password4jPasswordEncoder encoder = Password4jPasswordEncoder.defaultsForSpringSecurity(); - - assertThat(encoder.getAlgorithm()).isEqualTo(Password4jPasswordEncoder.Password4jAlgorithm.BCRYPT); - assertThat(encoder.getHashingFunction()).isInstanceOf(BcryptFunction.class); - - String encoded = encoder.encode(PASSWORD); - assertThat(encoded).startsWith("$2b$10$"); // Password4j uses $2b$ format - assertThat(encoder.matches(PASSWORD, encoded)).isTrue(); - } - - // Null and Empty Input Tests - @Test - void encodeNullPasswordShouldReturnNull() { - Password4jPasswordEncoder encoder = new Password4jPasswordEncoder(); - assertThat(encoder.encode(null)).isNull(); - } - - @Test - void encodeEmptyPasswordShouldWork() { - Password4jPasswordEncoder encoder = new Password4jPasswordEncoder(); - String encoded = encoder.encode(""); - assertThat(encoded).isNotNull(); - // AbstractValidatingPasswordEncoder returns false for empty raw passwords - assertThat(encoder.matches("", encoded)).isFalse(); - } - - @Test - void matchesWithNullOrEmptyParametersShouldReturnFalse() { - Password4jPasswordEncoder encoder = new Password4jPasswordEncoder(); - String validHash = encoder.encode(PASSWORD); - - assertThat(encoder.matches(null, validHash)).isFalse(); - assertThat(encoder.matches("", validHash)).isFalse(); - assertThat(encoder.matches(PASSWORD, null)).isFalse(); - assertThat(encoder.matches(PASSWORD, "")).isFalse(); - assertThat(encoder.matches(null, null)).isFalse(); - assertThat(encoder.matches("", "")).isFalse(); - } - - // Password Variety Tests - @ParameterizedTest - @ValueSource(strings = {"password", "123456", "P@ssw0rd!", "a very long password with spaces and symbols !@#$%"}) - void shouldHandleVariousPasswordFormats(String password) { - Password4jPasswordEncoder encoder = Password4jPasswordEncoder.defaultsForSpringSecurity(); - - String encoded = encoder.encode(password); - assertThat(encoded).isNotNull(); - assertThat(encoder.matches(password, encoded)).isTrue(); - assertThat(encoder.matches(password + "x", encoded)).isFalse(); - } - - @Test - void shouldHandleUnicodePasswords() { - Password4jPasswordEncoder encoder = Password4jPasswordEncoder.defaultsForSpringSecurity(); - - String encoded = encoder.encode(UNICODE_PASSWORD); - assertThat(encoded).isNotNull(); - assertThat(encoder.matches(UNICODE_PASSWORD, encoded)).isTrue(); - assertThat(encoder.matches("password", encoded)).isFalse(); - } - - @Test - void shouldHandleLongPasswords() { - Password4jPasswordEncoder encoder = Password4jPasswordEncoder.defaultsForSpringSecurity(); - - String encoded = encoder.encode(LONG_PASSWORD); - assertThat(encoded).isNotNull(); - assertThat(encoder.matches(LONG_PASSWORD, encoded)).isTrue(); - } - - // Upgrade Encoding Tests - @Test - void upgradeEncodingShouldReturnFalse() { - Password4jPasswordEncoder encoder = new Password4jPasswordEncoder(); - String encoded = encoder.encode(PASSWORD); - - // For now, upgradeEncoding should return false - assertThat(encoder.upgradeEncoding(encoded)).isFalse(); - assertThat(encoder.upgradeEncoding(null)).isFalse(); - assertThat(encoder.upgradeEncoding("")).isFalse(); - } - - @ParameterizedTest - @EnumSource(Password4jPasswordEncoder.Password4jAlgorithm.class) - void upgradeEncodingShouldReturnFalseForAllAlgorithms(Password4jPasswordEncoder.Password4jAlgorithm algorithm) { - Password4jPasswordEncoder encoder = new Password4jPasswordEncoder(algorithm); - String encoded = encoder.encode(PASSWORD); - - assertThat(encoder.upgradeEncoding(encoded)).isFalse(); - } - - // Custom Hashing Function Tests - @Test - void shouldWorkWithCustomHashingFunction() { - BcryptFunction customFunction = BcryptFunction.getInstance(12); - Password4jPasswordEncoder encoder = new Password4jPasswordEncoder(customFunction, Password4jPasswordEncoder.Password4jAlgorithm.BCRYPT); - - String encoded = encoder.encode(PASSWORD); - assertThat(encoded).startsWith("$2b$12$"); // Password4j uses $2b$ format - assertThat(encoder.matches(PASSWORD, encoded)).isTrue(); - } - - @Test - void shouldWorkWithCustomScryptFunction() { - ScryptFunction customFunction = ScryptFunction.getInstance(32768, 16, 2, 64); - Password4jPasswordEncoder encoder = new Password4jPasswordEncoder(customFunction, Password4jPasswordEncoder.Password4jAlgorithm.SCRYPT); - - String encoded = encoder.encode(PASSWORD); - assertThat(encoder.matches(PASSWORD, encoded)).isTrue(); - } - - @Test - void shouldWorkWithCustomArgon2Function() { - Argon2Function customFunction = Argon2Function.getInstance(131072, 4, 8, 64, Argon2.ID); - Password4jPasswordEncoder encoder = new Password4jPasswordEncoder(customFunction, Password4jPasswordEncoder.Password4jAlgorithm.ARGON2); - - String encoded = encoder.encode(PASSWORD); - assertThat(encoded).startsWith("$argon2id$"); - assertThat(encoder.matches(PASSWORD, encoded)).isTrue(); - } - - // Algorithm Coverage Tests - @Test - void shouldCreateEncoderForEachAlgorithm() { - // Test all algorithm types can be instantiated - for (Password4jPasswordEncoder.Password4jAlgorithm algorithm : Password4jPasswordEncoder.Password4jAlgorithm.values()) { - Password4jPasswordEncoder encoder = new Password4jPasswordEncoder(algorithm); - assertThat(encoder.getAlgorithm()).isEqualTo(algorithm); - - String encoded = encoder.encode(PASSWORD); - assertThat(encoded).isNotNull(); - assertThat(encoder.matches(PASSWORD, encoded)).isTrue(); - } - } - - @ParameterizedTest - @EnumSource(Password4jPasswordEncoder.Password4jAlgorithm.class) - void allAlgorithmsShouldProduceValidHashes(Password4jPasswordEncoder.Password4jAlgorithm algorithm) { - Password4jPasswordEncoder encoder = new Password4jPasswordEncoder(algorithm); - - String encoded = encoder.encode(PASSWORD); - assertThat(encoded) - .isNotNull() - .isNotEmpty() - .isNotEqualTo(PASSWORD); - - assertThat(encoder.matches(PASSWORD, encoded)).isTrue(); - assertThat(encoder.matches(WRONG_PASSWORD, encoded)).isFalse(); - } - - // Security Properties Tests - @RepeatedTest(10) - void samePasswordShouldProduceDifferentHashes() { - Password4jPasswordEncoder encoder = Password4jPasswordEncoder.defaultsForSpringSecurity(); - - String hash1 = encoder.encode(PASSWORD); - String hash2 = encoder.encode(PASSWORD); - - // Hashes should be different (due to salt) - assertThat(hash1).isNotEqualTo(hash2); - - // But both should verify correctly - assertThat(encoder.matches(PASSWORD, hash1)).isTrue(); - assertThat(encoder.matches(PASSWORD, hash2)).isTrue(); - } - - @Test - void hashLengthShouldBeConsistent() { - Password4jPasswordEncoder encoder = Password4jPasswordEncoder.defaultsForSpringSecurity(); - - String hash1 = encoder.encode("short"); - String hash2 = encoder.encode("this is a much longer password with many characters"); - - // BCrypt hashes should have consistent length - assertThat(hash1).hasSize(60); // BCrypt produces 60-character hashes - assertThat(hash2).hasSize(60); - } - - @Test - void similarPasswordsShouldProduceCompletelyDifferentHashes() { - Password4jPasswordEncoder encoder = Password4jPasswordEncoder.defaultsForSpringSecurity(); - - String hash1 = encoder.encode("password"); - String hash2 = encoder.encode("password1"); - String hash3 = encoder.encode("Password"); - - assertThat(hash1) - .isNotEqualTo(hash2) - .isNotEqualTo(hash3); - assertThat(hash2).isNotEqualTo(hash3); - - // Cross-verification should fail - assertThat(encoder.matches("password", hash2)).isFalse(); - assertThat(encoder.matches("password1", hash1)).isFalse(); - } - - - // Additional Security and Robustness Tests - @Test - void shouldHandleVeryLongPasswords() { - Password4jPasswordEncoder encoder = Password4jPasswordEncoder.defaultsForSpringSecurity(); - String veryLongPassword = "a".repeat(10000); // 10KB password - - String encoded = encoder.encode(veryLongPassword); - assertThat(encoded).isNotNull(); - assertThat(encoder.matches(veryLongPassword, encoded)).isTrue(); - // Fix: BCrypt truncates passwords longer than 72 bytes, so we need to test with a meaningful difference - // Test with a shorter difference that's within the 72-byte limit - String slightlyDifferentPassword = "b" + veryLongPassword.substring(1); // Change first character - assertThat(encoder.matches(slightlyDifferentPassword, encoded)).isFalse(); - } - - @Test - void shouldHandlePasswordsWithNullBytes() { - Password4jPasswordEncoder encoder = Password4jPasswordEncoder.defaultsForSpringSecurity(); - String passwordWithNull = "password\u0000test"; - - String encoded = encoder.encode(passwordWithNull); - assertThat(encoded).isNotNull(); - assertThat(encoder.matches(passwordWithNull, encoded)).isTrue(); - assertThat(encoder.matches("passwordtest", encoded)).isFalse(); - } - - @Test - void shouldProduceStrongRandomness() { - Password4jPasswordEncoder encoder = Password4jPasswordEncoder.defaultsForSpringSecurity(); - java.util.Set hashes = new java.util.HashSet<>(); - - // Generate many hashes of the same password - for (int i = 0; i < 100; i++) { - String hash = encoder.encode(PASSWORD); - assertThat(hashes.add(hash)).isTrue(); // Each hash should be unique - } - - assertThat(hashes).hasSize(100); - } - - @Test - void shouldResistTimingAttacks() { - Password4jPasswordEncoder encoder = Password4jPasswordEncoder.defaultsForSpringSecurity(); - String validHash = encoder.encode(PASSWORD); - - // Measure time for correct password - long startTime = System.nanoTime(); - boolean result1 = encoder.matches(PASSWORD, validHash); - long correctTime = System.nanoTime() - startTime; - - // Measure time for wrong password of same length - startTime = System.nanoTime(); - boolean result2 = encoder.matches("passwore", validHash); // Same length, different content - long wrongTime = System.nanoTime() - startTime; - - assertThat(result1).isTrue(); - assertThat(result2).isFalse(); - - // Times should be relatively close (within 10x factor for timing attack resistance) - double ratio = Math.max(correctTime, wrongTime) / (double) Math.min(correctTime, wrongTime); - assertThat(ratio).isLessThan(10.0); - } - - - @Test - void scryptShouldHandleEdgeCaseParameters() { - // Test with minimum viable parameters - Password4jPasswordEncoder encoder = Password4jPasswordEncoder.scrypt(2, 1, 1, 16); - - String encoded = encoder.encode(PASSWORD); - assertThat(encoder.matches(PASSWORD, encoded)).isTrue(); - } - - @Test - void argon2ShouldWorkWithDifferentMemorySizes() { - // Test with various memory configurations - int[] memorySizes = {1024, 4096, 16384, 65536}; - - for (int memory : memorySizes) { - Password4jPasswordEncoder encoder = Password4jPasswordEncoder.argon2(memory, 2, 1, 32, Argon2.ID); - String encoded = encoder.encode(PASSWORD); - assertThat(encoder.matches(PASSWORD, encoded)).isTrue(); - } - } - - @Test - void pbkdf2ShouldWorkWithDifferentHashAlgorithms() { - // Test that the implementation handles different internal configurations - Password4jPasswordEncoder encoder1 = Password4jPasswordEncoder.pbkdf2(50000, 16); - Password4jPasswordEncoder encoder2 = Password4jPasswordEncoder.pbkdf2(100000, 32); - Password4jPasswordEncoder encoder3 = Password4jPasswordEncoder.pbkdf2(200000, 64); - - String hash1 = encoder1.encode(PASSWORD); - String hash2 = encoder2.encode(PASSWORD); - String hash3 = encoder3.encode(PASSWORD); - - assertThat(encoder1.matches(PASSWORD, hash1)).isTrue(); - assertThat(encoder2.matches(PASSWORD, hash2)).isTrue(); - assertThat(encoder3.matches(PASSWORD, hash3)).isTrue(); - - // Hashes should be different due to different parameters - assertThat(hash1).isNotEqualTo(hash2); - assertThat(hash2).isNotEqualTo(hash3); - } - - // Cross-Algorithm Verification Tests - @Test - void differentAlgorithmsShouldNotCrossVerify() { - Password4jPasswordEncoder bcryptEncoder = Password4jPasswordEncoder.bcrypt(10); - Password4jPasswordEncoder scryptEncoder = Password4jPasswordEncoder.scrypt(16384, 8, 1, 32); - Password4jPasswordEncoder argon2Encoder = Password4jPasswordEncoder.argon2(65536, 3, 4, 32, Argon2.ID); - - String bcryptHash = bcryptEncoder.encode(PASSWORD); - String scryptHash = scryptEncoder.encode(PASSWORD); - String argon2Hash = argon2Encoder.encode(PASSWORD); - - // Each encoder should only verify its own hashes - assertThat(bcryptEncoder.matches(PASSWORD, bcryptHash)).isTrue(); - assertThat(bcryptEncoder.matches(PASSWORD, scryptHash)).isFalse(); - assertThat(bcryptEncoder.matches(PASSWORD, argon2Hash)).isFalse(); - - assertThat(scryptEncoder.matches(PASSWORD, scryptHash)).isTrue(); - assertThat(scryptEncoder.matches(PASSWORD, bcryptHash)).isFalse(); - assertThat(scryptEncoder.matches(PASSWORD, argon2Hash)).isFalse(); - - assertThat(argon2Encoder.matches(PASSWORD, argon2Hash)).isTrue(); - assertThat(argon2Encoder.matches(PASSWORD, bcryptHash)).isFalse(); - assertThat(argon2Encoder.matches(PASSWORD, scryptHash)).isFalse(); - } - - - @Test - void encodingShouldCompleteInReasonableTime() { - Password4jPasswordEncoder encoder = Password4jPasswordEncoder.defaultsForSpringSecurity(); - - long startTime = System.currentTimeMillis(); - String encoded = encoder.encode(PASSWORD); - long duration = System.currentTimeMillis() - startTime; - - assertThat(encoded).isNotNull(); - assertThat(duration).isLessThan(5000); // Should complete within 5 seconds - } - - // Compatibility and Integration Tests - @Test - void shouldBeCompatibleWithSpringSecurityConventions() { - Password4jPasswordEncoder encoder = Password4jPasswordEncoder.defaultsForSpringSecurity(); - - // Test common Spring Security patterns - assertThat(encoder.encode(null)).isNull(); - assertThat(encoder.matches(null, "hash")).isFalse(); - assertThat(encoder.matches("password", null)).isFalse(); - assertThat(encoder.upgradeEncoding("anyhash")).isFalse(); - - // Test that it follows AbstractValidatingPasswordEncoder contract - assertThat(encoder.matches("", "")).isFalse(); - assertThat(encoder.upgradeEncoding("")).isFalse(); - } - - @Test - void factoryMethodsShouldCreateCorrectInstances() { - // Verify all factory methods create properly configured instances - Password4jPasswordEncoder bcrypt = Password4jPasswordEncoder.bcrypt(12); - assertThat(bcrypt.getAlgorithm()).isEqualTo(Password4jPasswordEncoder.Password4jAlgorithm.BCRYPT); - assertThat(bcrypt.getHashingFunction()).isInstanceOf(BcryptFunction.class); - - Password4jPasswordEncoder scrypt = Password4jPasswordEncoder.scrypt(32768, 8, 1, 32); - assertThat(scrypt.getAlgorithm()).isEqualTo(Password4jPasswordEncoder.Password4jAlgorithm.SCRYPT); - assertThat(scrypt.getHashingFunction()).isInstanceOf(ScryptFunction.class); - - Password4jPasswordEncoder argon2 = Password4jPasswordEncoder.argon2(65536, 3, 4, 32, Argon2.ID); - assertThat(argon2.getAlgorithm()).isEqualTo(Password4jPasswordEncoder.Password4jAlgorithm.ARGON2); - assertThat(argon2.getHashingFunction()).isInstanceOf(Argon2Function.class); - - Password4jPasswordEncoder pbkdf2 = Password4jPasswordEncoder.pbkdf2(100000, 32); - assertThat(pbkdf2.getAlgorithm()).isEqualTo(Password4jPasswordEncoder.Password4jAlgorithm.PBKDF2); - assertThat(pbkdf2.getHashingFunction()).isInstanceOf(CompressedPBKDF2Function.class); - - Password4jPasswordEncoder compressedPbkdf2 = Password4jPasswordEncoder.compressedPbkdf2(100000, 32); - assertThat(compressedPbkdf2.getAlgorithm()).isEqualTo(Password4jPasswordEncoder.Password4jAlgorithm.COMPRESSED_PBKDF2); - assertThat(compressedPbkdf2.getHashingFunction()).isInstanceOf(CompressedPBKDF2Function.class); - } } 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] From 8c2ad4e4d170eb6a15b5f567370b35e13b91e0db Mon Sep 17 00:00:00 2001 From: Mehrdad Date: Tue, 9 Sep 2025 22:38:00 +0330 Subject: [PATCH 3/9] Add Argon2 and BCrypt and Scrypt password encoders using Password4j library Closes gh-17706 Signed-off-by: Mehrdad Signed-off-by: M.Bozorgmehr --- .../Argon2Password4jPasswordEncoder.java | 74 ++++++ .../BcryptPassword4jPasswordEncoder.java | 72 +++++ .../password4j/Password4jPasswordEncoder.java | 86 +----- .../ScryptPassword4jPasswordEncoder.java | 74 ++++++ .../Argon2Password4jPasswordEncoderTests.java | 245 +++++++++++++++++ .../BcryptPassword4jPasswordEncoderTests.java | 217 +++++++++++++++ .../Password4jPasswordEncoderTests.java | 90 ++----- .../PasswordCompatibilityTests.java | 92 ++----- .../ScryptPassword4jPasswordEncoderTests.java | 248 ++++++++++++++++++ 9 files changed, 994 insertions(+), 204 deletions(-) create mode 100644 crypto/src/main/java/org/springframework/security/crypto/password4j/Argon2Password4jPasswordEncoder.java create mode 100644 crypto/src/main/java/org/springframework/security/crypto/password4j/BcryptPassword4jPasswordEncoder.java create mode 100644 crypto/src/main/java/org/springframework/security/crypto/password4j/ScryptPassword4jPasswordEncoder.java create mode 100644 crypto/src/test/java/org/springframework/security/crypto/password4j/Argon2Password4jPasswordEncoderTests.java create mode 100644 crypto/src/test/java/org/springframework/security/crypto/password4j/BcryptPassword4jPasswordEncoderTests.java create mode 100644 crypto/src/test/java/org/springframework/security/crypto/password4j/ScryptPassword4jPasswordEncoderTests.java 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/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 index 36d286584fa..512da0e57ac 100644 --- a/crypto/src/main/java/org/springframework/security/crypto/password4j/Password4jPasswordEncoder.java +++ b/crypto/src/main/java/org/springframework/security/crypto/password4j/Password4jPasswordEncoder.java @@ -16,116 +16,56 @@ package org.springframework.security.crypto.password4j; -import com.password4j.AlgorithmFinder; import com.password4j.Hash; import com.password4j.HashingFunction; import com.password4j.Password; -import org.apache.commons.logging.Log; -import org.apache.commons.logging.LogFactory; import org.springframework.security.crypto.password.AbstractValidatingPasswordEncoder; import org.springframework.util.Assert; /** - * Implementation of {@link org.springframework.security.crypto.password.PasswordEncoder} - * that uses the Password4j library. This encoder supports multiple password hashing - * algorithms including BCrypt, SCrypt, Argon2, and PBKDF2. + * Abstract base class for Password4j-based password encoders. This class provides the + * common functionality for password encoding and verification using the Password4j + * library. * *

    - * The encoder uses the provided {@link HashingFunction} for both encoding and - * verification. Password4j can automatically detect the algorithm used in existing hashes - * during verification. + * 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. *

    * - *

    - * Usage Examples: - *

    - *
    {@code
    - * // Using default algorithms from AlgorithmFinder (recommended approach)
    - * PasswordEncoder bcryptEncoder = new Password4jPasswordEncoder(AlgorithmFinder.getBcryptInstance());
    - * PasswordEncoder argon2Encoder = new Password4jPasswordEncoder(AlgorithmFinder.getArgon2Instance());
    - * PasswordEncoder scryptEncoder = new Password4jPasswordEncoder(AlgorithmFinder.getScryptInstance());
    - * PasswordEncoder pbkdf2Encoder = new Password4jPasswordEncoder(AlgorithmFinder.getPBKDF2Instance());
    - *
    - * // Using customized algorithm parameters
    - * PasswordEncoder customBcrypt = new Password4jPasswordEncoder(BcryptFunction.getInstance(12));
    - * PasswordEncoder customArgon2 = new Password4jPasswordEncoder(
    - *     Argon2Function.getInstance(65536, 3, 4, 32, Argon2.ID));
    - * PasswordEncoder customScrypt = new Password4jPasswordEncoder(
    - *     ScryptFunction.getInstance(32768, 8, 1, 32));
    - * PasswordEncoder customPbkdf2 = new Password4jPasswordEncoder(
    - *     CompressedPBKDF2Function.getInstance("SHA256", 310000, 32));
    - * }
    - * * @author Mehrdad Bozorgmehr * @since 7.0 - * @see AlgorithmFinder */ -public class Password4jPasswordEncoder extends AbstractValidatingPasswordEncoder { - - private final Log logger = LogFactory.getLog(getClass()); +abstract class Password4jPasswordEncoder extends AbstractValidatingPasswordEncoder { private final HashingFunction hashingFunction; /** - * Constructs a Password4j password encoder with the specified hashing function. - * - *

    - * It is recommended to use password4j's {@link AlgorithmFinder} to obtain default - * instances with secure configurations: - *

    - *
      - *
    • {@code AlgorithmFinder.getBcryptInstance()} - BCrypt with default settings
    • - *
    • {@code AlgorithmFinder.getArgon2Instance()} - Argon2 with default settings
    • - *
    • {@code AlgorithmFinder.getScryptInstance()} - SCrypt with default settings
    • - *
    • {@code AlgorithmFinder.getPBKDF2Instance()} - PBKDF2 with default settings
    • - *
    - * - *

    - * For custom configurations, you can create specific function instances: - *

    - *
      - *
    • {@code BcryptFunction.getInstance(12)} - BCrypt with 12 rounds
    • - *
    • {@code Argon2Function.getInstance(65536, 3, 4, 32, Argon2.ID)} - Custom - * Argon2
    • - *
    • {@code ScryptFunction.getInstance(16384, 8, 1, 32)} - Custom SCrypt
    • - *
    • {@code CompressedPBKDF2Function.getInstance("SHA256", 310000, 32)} - Custom - * PBKDF2
    • - *
    + * 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 */ - public Password4jPasswordEncoder(HashingFunction hashingFunction) { + Password4jPasswordEncoder(HashingFunction hashingFunction) { Assert.notNull(hashingFunction, "hashingFunction cannot be null"); this.hashingFunction = hashingFunction; } @Override protected String encodeNonNullPassword(String rawPassword) { - try { - Hash hash = Password.hash(rawPassword).with(this.hashingFunction); - return hash.getResult(); - } - catch (Exception ex) { - throw new IllegalStateException("Failed to encode password using Password4j", ex); - } + Hash hash = Password.hash(rawPassword).with(this.hashingFunction); + return hash.getResult(); } @Override protected boolean matchesNonNull(String rawPassword, String encodedPassword) { - try { - // Use the specific hashing function for verification - return Password.check(rawPassword, encodedPassword).with(this.hashingFunction); - } - catch (Exception ex) { - this.logger.warn("Password verification failed for encoded password: " + encodedPassword, ex); - return false; - } + return Password.check(rawPassword, encodedPassword).with(this.hashingFunction); } @Override 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/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/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 index f4bec5dd4a6..f24bfbe42f9 100644 --- a/crypto/src/test/java/org/springframework/security/crypto/password4j/Password4jPasswordEncoderTests.java +++ b/crypto/src/test/java/org/springframework/security/crypto/password4j/Password4jPasswordEncoderTests.java @@ -16,16 +16,14 @@ package org.springframework.security.crypto.password4j; -import com.password4j.AlgorithmFinder; import com.password4j.BcryptFunction; -import com.password4j.HashingFunction; 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 Password4jPasswordEncoder}. + * Base functionality tests for {@link Password4jPasswordEncoder} implementations. These + * tests verify the common behavior across all concrete password encoder subclasses. * * @author Mehrdad Bozorgmehr */ @@ -35,27 +33,10 @@ class Password4jPasswordEncoderTests { private static final String WRONG_PASSWORD = "wrongpassword"; - // Constructor Tests - @Test - void constructorWithNullHashingFunctionShouldThrowException() { - assertThatIllegalArgumentException().isThrownBy(() -> new Password4jPasswordEncoder(null)) - .withMessage("hashingFunction cannot be null"); - } - - @Test - void constructorWithValidHashingFunctionShouldWork() { - HashingFunction hashingFunction = BcryptFunction.getInstance(10); - Password4jPasswordEncoder encoder = new Password4jPasswordEncoder(hashingFunction); - assertThat(encoder).isNotNull(); - } - - // Basic functionality tests with real HashingFunction instances + // Test abstract class behavior through concrete implementation @Test void encodeShouldReturnNonNullHashedPassword() { - HashingFunction hashingFunction = BcryptFunction.getInstance(4); // Use low cost - // for faster - // tests - Password4jPasswordEncoder encoder = new Password4jPasswordEncoder(hashingFunction); + BcryptPassword4jPasswordEncoder encoder = new BcryptPassword4jPasswordEncoder(BcryptFunction.getInstance(4)); String result = encoder.encode(PASSWORD); @@ -64,10 +45,7 @@ void encodeShouldReturnNonNullHashedPassword() { @Test void matchesShouldReturnTrueForValidPassword() { - HashingFunction hashingFunction = BcryptFunction.getInstance(4); // Use low cost - // for faster - // tests - Password4jPasswordEncoder encoder = new Password4jPasswordEncoder(hashingFunction); + BcryptPassword4jPasswordEncoder encoder = new BcryptPassword4jPasswordEncoder(BcryptFunction.getInstance(4)); String encoded = encoder.encode(PASSWORD); boolean result = encoder.matches(PASSWORD, encoded); @@ -77,10 +55,7 @@ void matchesShouldReturnTrueForValidPassword() { @Test void matchesShouldReturnFalseForInvalidPassword() { - HashingFunction hashingFunction = BcryptFunction.getInstance(4); // Use low cost - // for faster - // tests - Password4jPasswordEncoder encoder = new Password4jPasswordEncoder(hashingFunction); + BcryptPassword4jPasswordEncoder encoder = new BcryptPassword4jPasswordEncoder(BcryptFunction.getInstance(4)); String encoded = encoder.encode(PASSWORD); boolean result = encoder.matches(WRONG_PASSWORD, encoded); @@ -89,20 +64,27 @@ void matchesShouldReturnFalseForInvalidPassword() { } @Test - void matchesShouldReturnFalseForMalformedHash() { - HashingFunction hashingFunction = BcryptFunction.getInstance(4); - Password4jPasswordEncoder encoder = new Password4jPasswordEncoder(hashingFunction); + void encodeNullPasswordShouldReturnNull() { + BcryptPassword4jPasswordEncoder encoder = new BcryptPassword4jPasswordEncoder(BcryptFunction.getInstance(4)); - // Test with malformed hash that should cause Password4j to throw an exception - boolean result = encoder.matches(PASSWORD, "invalid-hash-format"); + assertThat(encoder.encode(null)).isNull(); + } - assertThat(result).isFalse(); + @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() { - HashingFunction hashingFunction = BcryptFunction.getInstance(4); - Password4jPasswordEncoder encoder = new Password4jPasswordEncoder(hashingFunction); + BcryptPassword4jPasswordEncoder encoder = new BcryptPassword4jPasswordEncoder(BcryptFunction.getInstance(4)); String encoded = encoder.encode(PASSWORD); boolean result = encoder.upgradeEncoding(encoded); @@ -110,32 +92,14 @@ void upgradeEncodingShouldReturnFalse() { assertThat(result).isFalse(); } - // AlgorithmFinder Sanity Check Tests - @Test - void algorithmFinderBcryptSanityCheck() { - Password4jPasswordEncoder encoder = new Password4jPasswordEncoder(AlgorithmFinder.getBcryptInstance()); - - String encoded = encoder.encode(PASSWORD); - assertThat(encoder.matches(PASSWORD, encoded)).isTrue(); - assertThat(encoder.matches(WRONG_PASSWORD, encoded)).isFalse(); - } - - @Test - void algorithmFinderArgon2SanityCheck() { - Password4jPasswordEncoder encoder = new Password4jPasswordEncoder(AlgorithmFinder.getArgon2Instance()); - - String encoded = encoder.encode(PASSWORD); - assertThat(encoder.matches(PASSWORD, encoded)).isTrue(); - assertThat(encoder.matches(WRONG_PASSWORD, encoded)).isFalse(); - } - @Test - void algorithmFinderScryptSanityCheck() { - Password4jPasswordEncoder encoder = new Password4jPasswordEncoder(AlgorithmFinder.getScryptInstance()); - + void matchesShouldReturnFalseWhenRawOrEncodedNullOrEmpty() { + BcryptPassword4jPasswordEncoder encoder = new BcryptPassword4jPasswordEncoder(BcryptFunction.getInstance(4)); String encoded = encoder.encode(PASSWORD); - assertThat(encoder.matches(PASSWORD, encoded)).isTrue(); - assertThat(encoder.matches(WRONG_PASSWORD, encoded)).isFalse(); + 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 index 44248aff442..6360cd164ee 100644 --- a/crypto/src/test/java/org/springframework/security/crypto/password4j/PasswordCompatibilityTests.java +++ b/crypto/src/test/java/org/springframework/security/crypto/password4j/PasswordCompatibilityTests.java @@ -16,24 +16,21 @@ package org.springframework.security.crypto.password4j; -import com.password4j.AlgorithmFinder; import com.password4j.Argon2Function; import com.password4j.BcryptFunction; -import com.password4j.CompressedPBKDF2Function; 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 - * {@link Password4jPasswordEncoder}. + * Password4j-based password encoders. * * @author Mehrdad Bozorgmehr */ @@ -45,7 +42,8 @@ class PasswordCompatibilityTests { @Test void bcryptEncodedWithSpringSecurityShouldMatchWithPassword4j() { BCryptPasswordEncoder springEncoder = new BCryptPasswordEncoder(10); - Password4jPasswordEncoder password4jEncoder = new Password4jPasswordEncoder(BcryptFunction.getInstance(10)); + BcryptPassword4jPasswordEncoder password4jEncoder = new BcryptPassword4jPasswordEncoder( + BcryptFunction.getInstance(10)); String encodedBySpring = springEncoder.encode(PASSWORD); boolean matchedByPassword4j = password4jEncoder.matches(PASSWORD, encodedBySpring); @@ -54,9 +52,10 @@ void bcryptEncodedWithSpringSecurityShouldMatchWithPassword4j() { } @Test - void bcryptEncodedWithPassword4jShouldMatchWithSpringSecurity() { + void bcryptEncodedWithPassword4jShouldMatchWithSpringSecirity() { + BcryptPassword4jPasswordEncoder password4jEncoder = new BcryptPassword4jPasswordEncoder( + BcryptFunction.getInstance(10)); BCryptPasswordEncoder springEncoder = new BCryptPasswordEncoder(10); - Password4jPasswordEncoder password4jEncoder = new Password4jPasswordEncoder(BcryptFunction.getInstance(10)); String encodedByPassword4j = password4jEncoder.encode(PASSWORD); boolean matchedBySpring = springEncoder.matches(PASSWORD, encodedByPassword4j); @@ -64,12 +63,12 @@ void bcryptEncodedWithPassword4jShouldMatchWithSpringSecurity() { assertThat(matchedBySpring).isTrue(); } - // SCrypt Compatibility Tests + // Argon2 Compatibility Tests @Test - void scryptEncodedWithSpringSecurityShouldMatchWithPassword4j() { - SCryptPasswordEncoder springEncoder = new SCryptPasswordEncoder(16384, 8, 1, 32, 64); - Password4jPasswordEncoder password4jEncoder = new Password4jPasswordEncoder( - ScryptFunction.getInstance(16384, 8, 1, 32)); + 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); @@ -78,10 +77,10 @@ void scryptEncodedWithSpringSecurityShouldMatchWithPassword4j() { } @Test - void scryptEncodedWithPassword4jShouldMatchWithSpringSecurity() { - SCryptPasswordEncoder springEncoder = new SCryptPasswordEncoder(16384, 8, 1, 32, 64); - Password4jPasswordEncoder password4jEncoder = new Password4jPasswordEncoder( - ScryptFunction.getInstance(16384, 8, 1, 32)); + void argon2EncodedWithPassword4jShouldMatchWithSpringSecirity() { + 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); @@ -89,12 +88,12 @@ void scryptEncodedWithPassword4jShouldMatchWithSpringSecurity() { assertThat(matchedBySpring).isTrue(); } - // Argon2 Compatibility Tests + // SCrypt Compatibility Tests @Test - void argon2EncodedWithSpringSecurityShouldMatchWithPassword4j() { - Argon2PasswordEncoder springEncoder = new Argon2PasswordEncoder(16, 32, 1, 65536, 3); - Password4jPasswordEncoder password4jEncoder = new Password4jPasswordEncoder( - Argon2Function.getInstance(65536, 3, 1, 32, Argon2.ID)); + 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); @@ -103,10 +102,10 @@ void argon2EncodedWithSpringSecurityShouldMatchWithPassword4j() { } @Test - void argon2EncodedWithPassword4jShouldMatchWithSpringSecurity() { - Argon2PasswordEncoder springEncoder = new Argon2PasswordEncoder(16, 32, 1, 65536, 3); - Password4jPasswordEncoder password4jEncoder = new Password4jPasswordEncoder( - Argon2Function.getInstance(65536, 3, 1, 32, Argon2.ID)); + void scryptEncodedWithPassword4jShouldMatchWithSpringSecirity() { + 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); @@ -114,47 +113,4 @@ void argon2EncodedWithPassword4jShouldMatchWithSpringSecurity() { assertThat(matchedBySpring).isTrue(); } - // PBKDF2 Compatibility Tests - Note: Different format implementations - @Test - void pbkdf2BasicFunctionalityTest() { - // Test that both encoders work independently with their own formats - // Spring Security PBKDF2 - Pbkdf2PasswordEncoder springEncoder = new Pbkdf2PasswordEncoder("", 16, 100000, - Pbkdf2PasswordEncoder.SecretKeyFactoryAlgorithm.PBKDF2WithHmacSHA256); - String springEncoded = springEncoder.encode(PASSWORD); - assertThat(springEncoder.matches(PASSWORD, springEncoded)).isTrue(); - - // Password4j PBKDF2 - Password4jPasswordEncoder password4jEncoder = new Password4jPasswordEncoder( - CompressedPBKDF2Function.getInstance("SHA256", 100000, 32)); - String password4jEncoded = password4jEncoder.encode(PASSWORD); - assertThat(password4jEncoder.matches(PASSWORD, password4jEncoded)).isTrue(); - - // Note: These encoders use different hash formats and are not cross-compatible - // This is expected behavior due to different implementation standards - } - - // Cross-Algorithm Tests (should fail) - @Test - void bcryptEncodedPasswordShouldNotMatchArgon2Encoder() { - Password4jPasswordEncoder bcryptEncoder = new Password4jPasswordEncoder(BcryptFunction.getInstance(10)); - Password4jPasswordEncoder argon2Encoder = new Password4jPasswordEncoder(AlgorithmFinder.getArgon2Instance()); - - String bcryptEncoded = bcryptEncoder.encode(PASSWORD); - boolean matchedByArgon2 = argon2Encoder.matches(PASSWORD, bcryptEncoded); - - assertThat(matchedByArgon2).isFalse(); - } - - @Test - void argon2EncodedPasswordShouldNotMatchScryptEncoder() { - Password4jPasswordEncoder argon2Encoder = new Password4jPasswordEncoder(AlgorithmFinder.getArgon2Instance()); - Password4jPasswordEncoder scryptEncoder = new Password4jPasswordEncoder(AlgorithmFinder.getScryptInstance()); - - String argon2Encoded = argon2Encoder.encode(PASSWORD); - boolean matchedByScrypt = scryptEncoder.matches(PASSWORD, argon2Encoded); - - assertThat(matchedByScrypt).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(); + } + +} From 2d74f9c334dc6b55a67c798df28b5b745c8a2aa7 Mon Sep 17 00:00:00 2001 From: Mehrdad Date: Fri, 12 Sep 2025 00:00:38 +0330 Subject: [PATCH 4/9] Create a specific implementation for BalloonHashing and PBKDF2 password encoders using Password4j library Closes gh-17706 Signed-off-by: Mehrdad Signed-off-by: M.Bozorgmehr --- ...lloonHashingPassword4jPasswordEncoder.java | 159 ++++++++++++++++ .../Pbkdf2Password4jPasswordEncoder.java | 157 ++++++++++++++++ ...HashingPassword4jPasswordEncoderTests.java | 170 ++++++++++++++++++ .../PasswordCompatibilityTests.java | 43 ++++- .../Pbkdf2Password4jPasswordEncoderTests.java | 167 +++++++++++++++++ 5 files changed, 693 insertions(+), 3 deletions(-) create mode 100644 crypto/src/main/java/org/springframework/security/crypto/password4j/BalloonHashingPassword4jPasswordEncoder.java create mode 100644 crypto/src/main/java/org/springframework/security/crypto/password4j/Pbkdf2Password4jPasswordEncoder.java create mode 100644 crypto/src/test/java/org/springframework/security/crypto/password4j/BalloonHashingPassword4jPasswordEncoderTests.java create mode 100644 crypto/src/test/java/org/springframework/security/crypto/password4j/Pbkdf2Password4jPasswordEncoderTests.java 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/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/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/PasswordCompatibilityTests.java b/crypto/src/test/java/org/springframework/security/crypto/password4j/PasswordCompatibilityTests.java index 6360cd164ee..d51e46e6e29 100644 --- a/crypto/src/test/java/org/springframework/security/crypto/password4j/PasswordCompatibilityTests.java +++ b/crypto/src/test/java/org/springframework/security/crypto/password4j/PasswordCompatibilityTests.java @@ -24,6 +24,7 @@ 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; @@ -52,7 +53,7 @@ void bcryptEncodedWithSpringSecurityShouldMatchWithPassword4j() { } @Test - void bcryptEncodedWithPassword4jShouldMatchWithSpringSecirity() { + void bcryptEncodedWithPassword4jShouldMatchWithSpringSecurity() { BcryptPassword4jPasswordEncoder password4jEncoder = new BcryptPassword4jPasswordEncoder( BcryptFunction.getInstance(10)); BCryptPasswordEncoder springEncoder = new BCryptPasswordEncoder(10); @@ -77,7 +78,7 @@ void argon2EncodedWithSpringSecurityShouldMatchWithPassword4j() { } @Test - void argon2EncodedWithPassword4jShouldMatchWithSpringSecirity() { + void argon2EncodedWithPassword4jShouldMatchWithSpringSecurity() { Argon2Password4jPasswordEncoder password4jEncoder = new Argon2Password4jPasswordEncoder( Argon2Function.getInstance(4096, 3, 1, 32, Argon2.ID)); Argon2PasswordEncoder springEncoder = new Argon2PasswordEncoder(16, 32, 1, 4096, 3); @@ -102,7 +103,7 @@ void scryptEncodedWithSpringSecurityShouldMatchWithPassword4j() { } @Test - void scryptEncodedWithPassword4jShouldMatchWithSpringSecirity() { + void scryptEncodedWithPassword4jShouldMatchWithSpringSecurity() { ScryptPassword4jPasswordEncoder password4jEncoder = new ScryptPassword4jPasswordEncoder( ScryptFunction.getInstance(16384, 8, 1, 32)); SCryptPasswordEncoder springEncoder = new SCryptPasswordEncoder(16384, 8, 1, 32, 64); @@ -113,4 +114,40 @@ void scryptEncodedWithPassword4jShouldMatchWithSpringSecirity() { 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(); + } + +} From b2d4c52c536cd3ff277baa0b864b19912dc84f5c Mon Sep 17 00:00:00 2001 From: "M.Bozorgmehr" Date: Sat, 13 Sep 2025 09:26:59 +0330 Subject: [PATCH 5/9] Add documentation for Password4j-based password encoders for Argon2, BCrypt, Scrypt, PBKDF2, and Balloon hashing Closes gh-17706 Signed-off-by: M.Bozorgmehr --- .../authentication/password-storage.adoc | 215 ++++++++++++++++++ docs/modules/ROOT/pages/whats-new.adoc | 9 + 2 files changed, 224 insertions(+) diff --git a/docs/modules/ROOT/pages/features/authentication/password-storage.adoc b/docs/modules/ROOT/pages/features/authentication/password-storage.adoc index 2ea6d68babb..7f0d5706014 100644 --- a/docs/modules/ROOT/pages/features/authentication/password-storage.adoc +++ b/docs/modules/ROOT/pages/features/authentication/password-storage.adoc @@ -463,6 +463,221 @@ 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. +[[authentication-password-storage-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. + +[[authentication-password-storage-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. + +.Argon2Password4jPasswordEncoder +[tabs] +====== +Java:: ++ +[source,java,role="primary"] +---- +// Create an encoder with default settings +Argon2Password4jPasswordEncoder encoder = new Argon2Password4jPasswordEncoder(); +String result = encoder.encode("myPassword"); +assertTrue(encoder.matches("myPassword", result)); + +// Create an encoder with custom Argon2 function +Argon2Function customArgon2 = Argon2Function.getInstance(65536, 3, 4, 32, Argon2.ID); +Argon2Password4jPasswordEncoder customEncoder = new Argon2Password4jPasswordEncoder(customArgon2); +---- + +Kotlin:: ++ +[source,kotlin,role="secondary"] +---- +// Create an encoder with default settings +val encoder = Argon2Password4jPasswordEncoder() +val result: String = encoder.encode("myPassword") +assertTrue(encoder.matches("myPassword", result)) + +// Create an encoder with custom Argon2 function +val customArgon2 = Argon2Function.getInstance(65536, 3, 4, 32, Argon2.ID) +val customEncoder = Argon2Password4jPasswordEncoder(customArgon2) +---- +====== + +[[authentication-password-storage-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. + +.BcryptPassword4jPasswordEncoder +[tabs] +====== +Java:: ++ +[source,java,role="primary"] +---- +// Create an encoder with default settings +BcryptPassword4jPasswordEncoder encoder = new BcryptPassword4jPasswordEncoder(); +String result = encoder.encode("myPassword"); +assertTrue(encoder.matches("myPassword", result)); + +// Create an encoder with custom round count +BcryptFunction customBcrypt = BcryptFunction.getInstance(12); +BcryptPassword4jPasswordEncoder customEncoder = new BcryptPassword4jPasswordEncoder(customBcrypt); +---- + +Kotlin:: ++ +[source,kotlin,role="secondary"] +---- +// Create an encoder with default settings +val encoder = BcryptPassword4jPasswordEncoder() +val result: String = encoder.encode("myPassword") +assertTrue(encoder.matches("myPassword", result)) + +// Create an encoder with custom round count +val customBcrypt = BcryptFunction.getInstance(12) +val customEncoder = BcryptPassword4jPasswordEncoder(customBcrypt) +---- +====== + +[[authentication-password-storage-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. + +.ScryptPassword4jPasswordEncoder +[tabs] +====== +Java:: ++ +[source,java,role="primary"] +---- +// Create an encoder with default settings +ScryptPassword4jPasswordEncoder encoder = new ScryptPassword4jPasswordEncoder(); +String result = encoder.encode("myPassword"); +assertTrue(encoder.matches("myPassword", result)); + +// Create an encoder with custom SCrypt parameters +ScryptFunction customScrypt = ScryptFunction.getInstance(32768, 8, 1, 32); +ScryptPassword4jPasswordEncoder customEncoder = new ScryptPassword4jPasswordEncoder(customScrypt); +---- + +Kotlin:: ++ +[source,kotlin,role="secondary"] +---- +// Create an encoder with default settings +val encoder = ScryptPassword4jPasswordEncoder() +val result: String = encoder.encode("myPassword") +assertTrue(encoder.matches("myPassword", result)) + +// Create an encoder with custom SCrypt parameters +val customScrypt = ScryptFunction.getInstance(32768, 8, 1, 32) +val customEncoder = ScryptPassword4jPasswordEncoder(customScrypt) +---- +====== + +[[authentication-password-storage-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. + +.Pbkdf2Password4jPasswordEncoder +[tabs] +====== +Java:: ++ +[source,java,role="primary"] +---- +// Create an encoder with default settings +Pbkdf2Password4jPasswordEncoder encoder = new Pbkdf2Password4jPasswordEncoder(); +String result = encoder.encode("myPassword"); +assertTrue(encoder.matches("myPassword", result)); + +// Create an encoder with custom PBKDF2 function and salt length +PBKDF2Function customPbkdf2 = PBKDF2Function.getInstance(Algorithm.HMAC_SHA256, 100000, 256); +Pbkdf2Password4jPasswordEncoder customEncoder = new Pbkdf2Password4jPasswordEncoder(customPbkdf2, 32); +---- + +Kotlin:: ++ +[source,kotlin,role="secondary"] +---- +// Create an encoder with default settings +val encoder = Pbkdf2Password4jPasswordEncoder() +val result: String = encoder.encode("myPassword") +assertTrue(encoder.matches("myPassword", result)) + +// Create an encoder with custom PBKDF2 function and salt length +val customPbkdf2 = PBKDF2Function.getInstance(Algorithm.HMAC_SHA256, 100000, 256) +val customEncoder = Pbkdf2Password4jPasswordEncoder(customPbkdf2, 32) +---- +====== + +[[authentication-password-storage-password4j-balloon]] +=== 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. + +.BalloonHashingPassword4jPasswordEncoder +[tabs] +====== +Java:: ++ +[source,java,role="primary"] +---- +// Create an encoder with default settings +BalloonHashingPassword4jPasswordEncoder encoder = new BalloonHashingPassword4jPasswordEncoder(); +String result = encoder.encode("myPassword"); +assertTrue(encoder.matches("myPassword", result)); + +// Create an encoder with custom Balloon hashing function and salt length +BalloonHashingFunction customBalloon = BalloonHashingFunction.getInstance(1024, 3, 4, "SHA-256"); +BalloonHashingPassword4jPasswordEncoder customEncoder = new BalloonHashingPassword4jPasswordEncoder(customBalloon, 32); +---- + +Kotlin:: ++ +[source,kotlin,role="secondary"] +---- +// Create an encoder with default settings +val encoder = BalloonHashingPassword4jPasswordEncoder() +val result: String = encoder.encode("myPassword") +assertTrue(encoder.matches("myPassword", result)) + +// Create an encoder with custom Balloon hashing function and salt length +val customBalloon = BalloonHashingFunction.getInstance(1024, 3, 4, "SHA-256") +val customEncoder = BalloonHashingPassword4jPasswordEncoder(customBalloon, 32) +---- +====== + [[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..a5ab6c66e59 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#authentication-password-storage-password4j-argon2[Argon2 implementation using Password4j] +** `BcryptPassword4jPasswordEncoder` - xref:features/authentication/password-storage.adoc#authentication-password-storage-password4j-bcrypt[BCrypt implementation using Password4j] +** `ScryptPassword4jPasswordEncoder` - xref:features/authentication/password-storage.adoc#authentication-password-storage-password4j-scrypt[SCrypt implementation using Password4j] +** `Pbkdf2Password4jPasswordEncoder` - xref:features/authentication/password-storage.adoc#authentication-password-storage-password4j-pbkdf2[PBKDF2 implementation using Password4j] +** `BalloonHashingPassword4jPasswordEncoder` - xref:features/authentication/password-storage.adoc#authentication-password-storage-password4j-balloon[Balloon Hashing implementation using Password4j] + == Data * Added support to Authorized objects for Spring Data types From c18aff7f5fcdbf468384557c90b91375313756e0 Mon Sep 17 00:00:00 2001 From: Rob Winch <362503+rwinch@users.noreply.github.com> Date: Mon, 15 Sep 2025 09:22:08 -0500 Subject: [PATCH 6/9] Password4j docs 1 sentence per line The Antora documentation convention is to use a single sentence per line as this helps with diffing and merging changes. Issue gh-17706 --- .../pages/features/authentication/password-storage.adoc | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/modules/ROOT/pages/features/authentication/password-storage.adoc b/docs/modules/ROOT/pages/features/authentication/password-storage.adoc index 7f0d5706014..13dfa194618 100644 --- a/docs/modules/ROOT/pages/features/authentication/password-storage.adoc +++ b/docs/modules/ROOT/pages/features/authentication/password-storage.adoc @@ -466,9 +466,11 @@ However, there are no plans to remove them, since it is difficult to migrate exi [[authentication-password-storage-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. +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. +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. From 11bec09ffc8062788e85e4534b58b0ab8ef92859 Mon Sep 17 00:00:00 2001 From: Rob Winch <362503+rwinch@users.noreply.github.com> Date: Mon, 15 Sep 2025 10:57:19 -0500 Subject: [PATCH 7/9] Escape attribute failures in Password4j docs Issue gh-17706 --- .../ROOT/pages/features/authentication/password-storage.adoc | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/modules/ROOT/pages/features/authentication/password-storage.adoc b/docs/modules/ROOT/pages/features/authentication/password-storage.adoc index 13dfa194618..7ae143c37f3 100644 --- a/docs/modules/ROOT/pages/features/authentication/password-storage.adoc +++ b/docs/modules/ROOT/pages/features/authentication/password-storage.adoc @@ -605,7 +605,7 @@ This provides an alternative to Spring Security's built-in `Pbkdf2PasswordEncode 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. +The encoded password format is: `+{salt}:{hash}+` where both salt and hash are Base64 encoded. .Pbkdf2Password4jPasswordEncoder [tabs] @@ -646,7 +646,7 @@ The `BalloonHashingPassword4jPasswordEncoder` implementation uses the Balloon ha 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. +The encoded password format is: `+{salt}:{hash}+` where both salt and hash are Base64 encoded. .BalloonHashingPassword4jPasswordEncoder [tabs] From 9f839384e9f3760cebde23a2f203f719abc12032 Mon Sep 17 00:00:00 2001 From: Rob Winch <362503+rwinch@users.noreply.github.com> Date: Mon, 15 Sep 2025 11:00:51 -0500 Subject: [PATCH 8/9] Use non-redundant ids in password4j docs Documentation ids no longer need to be globally unique, so they do not need to include the path. This makes the ids less verbose and integrates with include-code extension better. Issue gh-17706 --- .../features/authentication/password-storage.adoc | 12 ++++++------ docs/modules/ROOT/pages/whats-new.adoc | 10 +++++----- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/docs/modules/ROOT/pages/features/authentication/password-storage.adoc b/docs/modules/ROOT/pages/features/authentication/password-storage.adoc index 7ae143c37f3..c5158ba85a2 100644 --- a/docs/modules/ROOT/pages/features/authentication/password-storage.adoc +++ b/docs/modules/ROOT/pages/features/authentication/password-storage.adoc @@ -463,7 +463,7 @@ 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. -[[authentication-password-storage-password4j]] +[[password4j]] == Password4j-based Password Encoders Spring Security 7.0 introduces alternative password encoder implementations based on the https://github.com/Password4j/password4j[Password4j] library. @@ -474,7 +474,7 @@ These encoders are particularly useful when you need specific algorithm configur All Password4j-based encoders are thread-safe and can be shared across multiple threads. -[[authentication-password-storage-password4j-argon2]] +[[password4j-argon2]] === Argon2Password4jPasswordEncoder The `Argon2Password4jPasswordEncoder` implementation uses the https://en.wikipedia.org/wiki/Argon2[Argon2] algorithm via the Password4j library to hash passwords. @@ -515,7 +515,7 @@ val customEncoder = Argon2Password4jPasswordEncoder(customArgon2) ---- ====== -[[authentication-password-storage-password4j-bcrypt]] +[[password4j-bcrypt]] === BcryptPassword4jPasswordEncoder The `BcryptPassword4jPasswordEncoder` implementation uses the https://en.wikipedia.org/wiki/Bcrypt[BCrypt] algorithm via the Password4j library to hash passwords. @@ -556,7 +556,7 @@ val customEncoder = BcryptPassword4jPasswordEncoder(customBcrypt) ---- ====== -[[authentication-password-storage-password4j-scrypt]] +[[password4j-scrypt]] === ScryptPassword4jPasswordEncoder The `ScryptPassword4jPasswordEncoder` implementation uses the https://en.wikipedia.org/wiki/Scrypt[SCrypt] algorithm via the Password4j library to hash passwords. @@ -597,7 +597,7 @@ val customEncoder = ScryptPassword4jPasswordEncoder(customScrypt) ---- ====== -[[authentication-password-storage-password4j-pbkdf2]] +[[password4j-pbkdf2]] === Pbkdf2Password4jPasswordEncoder The `Pbkdf2Password4jPasswordEncoder` implementation uses the https://en.wikipedia.org/wiki/PBKDF2[PBKDF2] algorithm via the Password4j library to hash passwords. @@ -639,7 +639,7 @@ val customEncoder = Pbkdf2Password4jPasswordEncoder(customPbkdf2, 32) ---- ====== -[[authentication-password-storage-password4j-balloon]] +[[password4j-ballooning]] === BalloonHashingPassword4jPasswordEncoder The `BalloonHashingPassword4jPasswordEncoder` implementation uses the Balloon hashing algorithm via the Password4j library to hash passwords. diff --git a/docs/modules/ROOT/pages/whats-new.adoc b/docs/modules/ROOT/pages/whats-new.adoc index a5ab6c66e59..c150e561132 100644 --- a/docs/modules/ROOT/pages/whats-new.adoc +++ b/docs/modules/ROOT/pages/whats-new.adoc @@ -38,11 +38,11 @@ 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#authentication-password-storage-password4j-argon2[Argon2 implementation using Password4j] -** `BcryptPassword4jPasswordEncoder` - xref:features/authentication/password-storage.adoc#authentication-password-storage-password4j-bcrypt[BCrypt implementation using Password4j] -** `ScryptPassword4jPasswordEncoder` - xref:features/authentication/password-storage.adoc#authentication-password-storage-password4j-scrypt[SCrypt implementation using Password4j] -** `Pbkdf2Password4jPasswordEncoder` - xref:features/authentication/password-storage.adoc#authentication-password-storage-password4j-pbkdf2[PBKDF2 implementation using Password4j] -** `BalloonHashingPassword4jPasswordEncoder` - xref:features/authentication/password-storage.adoc#authentication-password-storage-password4j-balloon[Balloon Hashing implementation using Password4j] +** `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 From d0372efadd8f2bcf8bd0d42d3592e2fa23dd0487 Mon Sep 17 00:00:00 2001 From: Rob Winch <362503+rwinch@users.noreply.github.com> Date: Mon, 15 Sep 2025 11:03:44 -0500 Subject: [PATCH 9/9] Use include-code for password4j docs This follows the new convention of using include-code going forward to ensure that the documentation compiles and is tested. This also corrected a few errors in custom params for Ballooning and PBKDF2 examples. Issue gh-17706 --- crypto/spring-security-crypto.gradle | 2 +- .../authentication/password-storage.adoc | 172 ++++-------------- docs/spring-security-docs.gradle | 1 + .../password4jargon2/Argon2UsageTests.java | 53 ++++++ .../BallooningHashingUsageTests.java | 52 ++++++ .../password4jbcrypt/BcryptUsageTests.java | 52 ++++++ .../password4jpbkdf2/Pbkdf2UsageTests.java | 52 ++++++ .../password4jscrypt/ScryptUsageTests.java | 51 ++++++ .../password4jargon2/Argon2UsageTests.kt | 51 ++++++ .../BallooningHashingUsageTests.kt | 47 +++++ .../password4jbcrypt/BcryptUsageTests.kt | 32 ++++ .../password4jpbkdf2/Pbkdf2UsageTests.kt | 32 ++++ .../password4jscrypt/ScryptUsageTests.kt | 31 ++++ 13 files changed, 487 insertions(+), 141 deletions(-) create mode 100644 docs/src/test/java/org/springframework/security/docs/features/authentication/password4jargon2/Argon2UsageTests.java create mode 100644 docs/src/test/java/org/springframework/security/docs/features/authentication/password4jballooning/BallooningHashingUsageTests.java create mode 100644 docs/src/test/java/org/springframework/security/docs/features/authentication/password4jbcrypt/BcryptUsageTests.java create mode 100644 docs/src/test/java/org/springframework/security/docs/features/authentication/password4jpbkdf2/Pbkdf2UsageTests.java create mode 100644 docs/src/test/java/org/springframework/security/docs/features/authentication/password4jscrypt/ScryptUsageTests.java create mode 100644 docs/src/test/kotlin/org/springframework/security/kt/docs/features/authentication/password4jargon2/Argon2UsageTests.kt create mode 100644 docs/src/test/kotlin/org/springframework/security/kt/docs/features/authentication/password4jballooning/BallooningHashingUsageTests.kt create mode 100644 docs/src/test/kotlin/org/springframework/security/kt/docs/features/authentication/password4jbcrypt/BcryptUsageTests.kt create mode 100644 docs/src/test/kotlin/org/springframework/security/kt/docs/features/authentication/password4jpbkdf2/Pbkdf2UsageTests.kt create mode 100644 docs/src/test/kotlin/org/springframework/security/kt/docs/features/authentication/password4jscrypt/ScryptUsageTests.kt diff --git a/crypto/spring-security-crypto.gradle b/crypto/spring-security-crypto.gradle index 6b2b478422b..7fd438016db 100644 --- a/crypto/spring-security-crypto.gradle +++ b/crypto/spring-security-crypto.gradle @@ -8,7 +8,7 @@ dependencies { management platform(project(":spring-security-dependencies")) optional 'org.springframework:spring-core' optional 'org.bouncycastle:bcpkix-jdk18on' - optional 'com.password4j:password4j' + optional libs.com.password4j.password4j testImplementation "org.assertj:assertj-core" testImplementation "org.junit.jupiter:junit-jupiter-api" diff --git a/docs/modules/ROOT/pages/features/authentication/password-storage.adoc b/docs/modules/ROOT/pages/features/authentication/password-storage.adoc index c5158ba85a2..48b6983433d 100644 --- a/docs/modules/ROOT/pages/features/authentication/password-storage.adoc +++ b/docs/modules/ROOT/pages/features/authentication/password-storage.adoc @@ -483,37 +483,15 @@ This provides an alternative to Spring Security's built-in `Argon2PasswordEncode 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 -[tabs] -====== -Java:: -+ -[source,java,role="primary"] ----- -// Create an encoder with default settings -Argon2Password4jPasswordEncoder encoder = new Argon2Password4jPasswordEncoder(); -String result = encoder.encode("myPassword"); -assertTrue(encoder.matches("myPassword", result)); +include-code::./Argon2UsageTests[tag=default-params,indent=0] -// Create an encoder with custom Argon2 function -Argon2Function customArgon2 = Argon2Function.getInstance(65536, 3, 4, 32, Argon2.ID); -Argon2Password4jPasswordEncoder customEncoder = new Argon2Password4jPasswordEncoder(customArgon2); ----- +Create an encoder with custom Argon2 parameters: -Kotlin:: -+ -[source,kotlin,role="secondary"] ----- -// Create an encoder with default settings -val encoder = Argon2Password4jPasswordEncoder() -val result: String = encoder.encode("myPassword") -assertTrue(encoder.matches("myPassword", result)) - -// Create an encoder with custom Argon2 function -val customArgon2 = Argon2Function.getInstance(65536, 3, 4, 32, Argon2.ID) -val customEncoder = Argon2Password4jPasswordEncoder(customArgon2) ----- -====== +.Argon2Password4jPasswordEncoder Custom +include-code::./Argon2UsageTests[tag=custom-params,indent=0] [[password4j-bcrypt]] === BcryptPassword4jPasswordEncoder @@ -524,37 +502,15 @@ This provides an alternative to Spring Security's built-in `BCryptPasswordEncode 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. -.BcryptPassword4jPasswordEncoder -[tabs] -====== -Java:: -+ -[source,java,role="primary"] ----- -// Create an encoder with default settings -BcryptPassword4jPasswordEncoder encoder = new BcryptPassword4jPasswordEncoder(); -String result = encoder.encode("myPassword"); -assertTrue(encoder.matches("myPassword", result)); +Create an encoder with default settings: -// Create an encoder with custom round count -BcryptFunction customBcrypt = BcryptFunction.getInstance(12); -BcryptPassword4jPasswordEncoder customEncoder = new BcryptPassword4jPasswordEncoder(customBcrypt); ----- +.BcryptPassword4jPasswordEncoder +include-code::./BcryptUsageTests[tag=default-params,indent=0] -Kotlin:: -+ -[source,kotlin,role="secondary"] ----- -// Create an encoder with default settings -val encoder = BcryptPassword4jPasswordEncoder() -val result: String = encoder.encode("myPassword") -assertTrue(encoder.matches("myPassword", result)) +Create an encoder with custom bcrypt parameters: -// Create an encoder with custom round count -val customBcrypt = BcryptFunction.getInstance(12) -val customEncoder = BcryptPassword4jPasswordEncoder(customBcrypt) ----- -====== +.BcryptPassword4jPasswordEncoder Custom +include-code::./BcryptUsageTests[tag=custom-params,indent=0] [[password4j-scrypt]] === ScryptPassword4jPasswordEncoder @@ -565,37 +521,16 @@ This provides an alternative to Spring Security's built-in `SCryptPasswordEncode 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. -.ScryptPassword4jPasswordEncoder -[tabs] -====== -Java:: -+ -[source,java,role="primary"] ----- -// Create an encoder with default settings -ScryptPassword4jPasswordEncoder encoder = new ScryptPassword4jPasswordEncoder(); -String result = encoder.encode("myPassword"); -assertTrue(encoder.matches("myPassword", result)); -// Create an encoder with custom SCrypt parameters -ScryptFunction customScrypt = ScryptFunction.getInstance(32768, 8, 1, 32); -ScryptPassword4jPasswordEncoder customEncoder = new ScryptPassword4jPasswordEncoder(customScrypt); ----- +Create an encoder with default settings: -Kotlin:: -+ -[source,kotlin,role="secondary"] ----- -// Create an encoder with default settings -val encoder = ScryptPassword4jPasswordEncoder() -val result: String = encoder.encode("myPassword") -assertTrue(encoder.matches("myPassword", result)) +.ScryptPassword4jPasswordEncoder +include-code::./ScryptUsageTests[tag=default-params,indent=0] -// Create an encoder with custom SCrypt parameters -val customScrypt = ScryptFunction.getInstance(32768, 8, 1, 32) -val customEncoder = ScryptPassword4jPasswordEncoder(customScrypt) ----- -====== +Create an encoder with custom scrypt parameters: + +.ScryptPassword4jPasswordEncoder Custom +include-code::./ScryptUsageTests[tag=custom-params,indent=0] [[password4j-pbkdf2]] === Pbkdf2Password4jPasswordEncoder @@ -607,37 +542,15 @@ PBKDF2 is a key derivation function designed to be computationally expensive to 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. -.Pbkdf2Password4jPasswordEncoder -[tabs] -====== -Java:: -+ -[source,java,role="primary"] ----- -// Create an encoder with default settings -Pbkdf2Password4jPasswordEncoder encoder = new Pbkdf2Password4jPasswordEncoder(); -String result = encoder.encode("myPassword"); -assertTrue(encoder.matches("myPassword", result)); +Create an encoder with default settings: -// Create an encoder with custom PBKDF2 function and salt length -PBKDF2Function customPbkdf2 = PBKDF2Function.getInstance(Algorithm.HMAC_SHA256, 100000, 256); -Pbkdf2Password4jPasswordEncoder customEncoder = new Pbkdf2Password4jPasswordEncoder(customPbkdf2, 32); ----- +.Pbkdf2Password4jPasswordEncoder +include-code::./Pbkdf2UsageTests[tag=default-params,indent=0] -Kotlin:: -+ -[source,kotlin,role="secondary"] ----- -// Create an encoder with default settings -val encoder = Pbkdf2Password4jPasswordEncoder() -val result: String = encoder.encode("myPassword") -assertTrue(encoder.matches("myPassword", result)) +Create an encoder with custom PBKDF2 parameters: -// Create an encoder with custom PBKDF2 function and salt length -val customPbkdf2 = PBKDF2Function.getInstance(Algorithm.HMAC_SHA256, 100000, 256) -val customEncoder = Pbkdf2Password4jPasswordEncoder(customPbkdf2, 32) ----- -====== +.Pbkdf2Password4jPasswordEncoder Custom +include-code::./Pbkdf2UsageTests[tag=custom-params,indent=0] [[password4j-ballooning]] === BalloonHashingPassword4jPasswordEncoder @@ -648,37 +561,16 @@ Balloon hashing is a memory-hard password hashing algorithm designed to be resis 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. -.BalloonHashingPassword4jPasswordEncoder -[tabs] -====== -Java:: -+ -[source,java,role="primary"] ----- -// Create an encoder with default settings -BalloonHashingPassword4jPasswordEncoder encoder = new BalloonHashingPassword4jPasswordEncoder(); -String result = encoder.encode("myPassword"); -assertTrue(encoder.matches("myPassword", result)); -// Create an encoder with custom Balloon hashing function and salt length -BalloonHashingFunction customBalloon = BalloonHashingFunction.getInstance(1024, 3, 4, "SHA-256"); -BalloonHashingPassword4jPasswordEncoder customEncoder = new BalloonHashingPassword4jPasswordEncoder(customBalloon, 32); ----- +Create an encoder with default settings: -Kotlin:: -+ -[source,kotlin,role="secondary"] ----- -// Create an encoder with default settings -val encoder = BalloonHashingPassword4jPasswordEncoder() -val result: String = encoder.encode("myPassword") -assertTrue(encoder.matches("myPassword", result)) +.BalloonHashingPassword4jPasswordEncoder +include-code::./BallooningHashingUsageTests[tag=default-params,indent=0] -// Create an encoder with custom Balloon hashing function and salt length -val customBalloon = BalloonHashingFunction.getInstance(1024, 3, 4, "SHA-256") -val customEncoder = BalloonHashingPassword4jPasswordEncoder(customBalloon, 32) ----- -====== +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/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[] + } +}