Refactor Password4jPasswordEncoder to use AlgorithmFinder for algorithm selection and enhance documentation
Closes gh-17706 Signed-off-by: M.Bozorgmehr <mehrdad.bozorgmehr@gmail.com> Signed-off-by: Mehrdad <mehrdad.bozorgmehr@gmail.com> Signed-off-by: M.Bozorgmehr <mehrdad.bozorgmehr@gmail.com>
This commit is contained in:
		
							parent
							
								
									bd593a63d0
								
							
						
					
					
						commit
						9f5d27e8d0
					
				|  | @ -24,7 +24,6 @@ import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; | ||||||
| import org.springframework.security.crypto.password.DelegatingPasswordEncoder; | import org.springframework.security.crypto.password.DelegatingPasswordEncoder; | ||||||
| import org.springframework.security.crypto.password.PasswordEncoder; | import org.springframework.security.crypto.password.PasswordEncoder; | ||||||
| import org.springframework.security.crypto.password.Pbkdf2PasswordEncoder; | import org.springframework.security.crypto.password.Pbkdf2PasswordEncoder; | ||||||
| import org.springframework.security.crypto.password4j.Password4jPasswordEncoder; |  | ||||||
| import org.springframework.security.crypto.scrypt.SCryptPasswordEncoder; | import org.springframework.security.crypto.scrypt.SCryptPasswordEncoder; | ||||||
| 
 | 
 | ||||||
| /** | /** | ||||||
|  | @ -66,10 +65,6 @@ public final class PasswordEncoderFactories { | ||||||
| 	 * <li>argon2 - {@link Argon2PasswordEncoder#defaultsForSpringSecurity_v5_2()}</li> | 	 * <li>argon2 - {@link Argon2PasswordEncoder#defaultsForSpringSecurity_v5_2()}</li> | ||||||
| 	 * <li>argon2@SpringSecurity_v5_8 - | 	 * <li>argon2@SpringSecurity_v5_8 - | ||||||
| 	 * {@link Argon2PasswordEncoder#defaultsForSpringSecurity_v5_8()}</li> | 	 * {@link Argon2PasswordEncoder#defaultsForSpringSecurity_v5_8()}</li> | ||||||
| 	 * <li>password4j-bcrypt - {@link Password4jPasswordEncoder} with BCrypt</li> |  | ||||||
| 	 * <li>password4j-scrypt - {@link Password4jPasswordEncoder} with SCrypt</li> |  | ||||||
| 	 * <li>password4j-argon2 - {@link Password4jPasswordEncoder} with Argon2</li> |  | ||||||
| 	 * <li>password4j-pbkdf2 - {@link Password4jPasswordEncoder} with PBKDF2</li> |  | ||||||
| 	 * </ul> | 	 * </ul> | ||||||
| 	 * @return the {@link PasswordEncoder} to use | 	 * @return the {@link PasswordEncoder} to use | ||||||
| 	 */ | 	 */ | ||||||
|  | @ -92,14 +87,6 @@ public final class PasswordEncoderFactories { | ||||||
| 		encoders.put("sha256", new org.springframework.security.crypto.password.StandardPasswordEncoder()); | 		encoders.put("sha256", new org.springframework.security.crypto.password.StandardPasswordEncoder()); | ||||||
| 		encoders.put("argon2", Argon2PasswordEncoder.defaultsForSpringSecurity_v5_2()); | 		encoders.put("argon2", Argon2PasswordEncoder.defaultsForSpringSecurity_v5_2()); | ||||||
| 		encoders.put("argon2@SpringSecurity_v5_8", Argon2PasswordEncoder.defaultsForSpringSecurity_v5_8()); | 		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); | 		return new DelegatingPasswordEncoder(encodingId, encoders); | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -16,24 +16,54 @@ | ||||||
| 
 | 
 | ||||||
| package org.springframework.security.crypto.password4j; | package org.springframework.security.crypto.password4j; | ||||||
| 
 | 
 | ||||||
| import com.password4j.*; | import com.password4j.AlgorithmFinder; | ||||||
| import com.password4j.types.Argon2; | import com.password4j.Hash; | ||||||
|  | import com.password4j.HashingFunction; | ||||||
|  | import com.password4j.Password; | ||||||
| import org.apache.commons.logging.Log; | import org.apache.commons.logging.Log; | ||||||
| import org.apache.commons.logging.LogFactory; | import org.apache.commons.logging.LogFactory; | ||||||
|  | 
 | ||||||
| import org.springframework.security.crypto.password.AbstractValidatingPasswordEncoder; | import org.springframework.security.crypto.password.AbstractValidatingPasswordEncoder; | ||||||
| import org.springframework.util.Assert; | import org.springframework.util.Assert; | ||||||
| 
 | 
 | ||||||
| /** | /** | ||||||
|  * Implementation of {@link org.springframework.security.crypto.password.PasswordEncoder} that uses the Password4j library. |  * Implementation of {@link org.springframework.security.crypto.password.PasswordEncoder} | ||||||
|  * This encoder supports multiple password hashing algorithms including BCrypt, SCrypt, Argon2, and PBKDF2. |  * that uses the Password4j library. This encoder supports multiple password hashing | ||||||
|  |  * algorithms including BCrypt, SCrypt, Argon2, and PBKDF2. | ||||||
|  * |  * | ||||||
|  * <p>The encoder determines the algorithm used based on the algorithm type specified during construction. |  * <p> | ||||||
|  * For verification, it can automatically detect the algorithm used in existing hashes.</p> |  * The encoder uses the provided {@link HashingFunction} for both encoding and | ||||||
|  |  * verification. Password4j can automatically detect the algorithm used in existing hashes | ||||||
|  |  * during verification. | ||||||
|  |  * </p> | ||||||
|  * |  * | ||||||
|  * <p>This implementation is thread-safe and can be shared across multiple threads.</p> |  * <p> | ||||||
|  |  * This implementation is thread-safe and can be shared across multiple threads. | ||||||
|  |  * </p> | ||||||
|  |  * | ||||||
|  |  * <p> | ||||||
|  |  * <strong>Usage Examples:</strong> | ||||||
|  |  * </p> | ||||||
|  |  * <pre>{@code | ||||||
|  |  * // Using default algorithms from AlgorithmFinder (recommended approach) | ||||||
|  |  * PasswordEncoder bcryptEncoder = new Password4jPasswordEncoder(AlgorithmFinder.getBcryptInstance()); | ||||||
|  |  * PasswordEncoder argon2Encoder = new Password4jPasswordEncoder(AlgorithmFinder.getArgon2Instance()); | ||||||
|  |  * PasswordEncoder scryptEncoder = new Password4jPasswordEncoder(AlgorithmFinder.getScryptInstance()); | ||||||
|  |  * PasswordEncoder pbkdf2Encoder = new Password4jPasswordEncoder(AlgorithmFinder.getPBKDF2Instance()); | ||||||
|  |  * | ||||||
|  |  * // Using customized algorithm parameters | ||||||
|  |  * PasswordEncoder customBcrypt = new Password4jPasswordEncoder(BcryptFunction.getInstance(12)); | ||||||
|  |  * PasswordEncoder customArgon2 = new Password4jPasswordEncoder( | ||||||
|  |  *     Argon2Function.getInstance(65536, 3, 4, 32, Argon2.ID)); | ||||||
|  |  * PasswordEncoder customScrypt = new Password4jPasswordEncoder( | ||||||
|  |  *     ScryptFunction.getInstance(32768, 8, 1, 32)); | ||||||
|  |  * PasswordEncoder customPbkdf2 = new Password4jPasswordEncoder( | ||||||
|  |  *     CompressedPBKDF2Function.getInstance("SHA256", 310000, 32)); | ||||||
|  |  * }</pre> | ||||||
|  * |  * | ||||||
|  * @author Mehrdad Bozorgmehr |  * @author Mehrdad Bozorgmehr | ||||||
|  * @since 6.5 |  * @since 7.0 | ||||||
|  |  * @see AlgorithmFinder | ||||||
|  */ |  */ | ||||||
| public class Password4jPasswordEncoder extends AbstractValidatingPasswordEncoder { | public class Password4jPasswordEncoder extends AbstractValidatingPasswordEncoder { | ||||||
| 
 | 
 | ||||||
|  | @ -41,146 +71,38 @@ public class Password4jPasswordEncoder extends AbstractValidatingPasswordEncoder | ||||||
| 
 | 
 | ||||||
| 	private final HashingFunction hashingFunction; | 	private final HashingFunction hashingFunction; | ||||||
| 
 | 
 | ||||||
| 	private final Password4jAlgorithm algorithm; |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| 	/** | 	/** | ||||||
| 	 * Enumeration of supported Password4j algorithms. | 	 * Constructs a Password4j password encoder with the specified hashing function. | ||||||
| 	 */ |  | ||||||
| 	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 | 	 * <p> | ||||||
| 	 */ | 	 * It is recommended to use password4j's {@link AlgorithmFinder} to obtain default | ||||||
| 	public Password4jPasswordEncoder(Password4jAlgorithm algorithm) { | 	 * instances with secure configurations: | ||||||
| 		Assert.notNull(algorithm, "algorithm cannot be null"); | 	 * </p> | ||||||
| 		this.algorithm = algorithm; | 	 * <ul> | ||||||
| 		this.hashingFunction = createDefaultHashingFunction(algorithm); | 	 * <li>{@code AlgorithmFinder.getBcryptInstance()} - BCrypt with default settings</li> | ||||||
| 	} | 	 * <li>{@code AlgorithmFinder.getArgon2Instance()} - Argon2 with default settings</li> | ||||||
| 
 | 	 * <li>{@code AlgorithmFinder.getScryptInstance()} - SCrypt with default settings</li> | ||||||
| 	/** | 	 * <li>{@code AlgorithmFinder.getPBKDF2Instance()} - PBKDF2 with default settings</li> | ||||||
| 	 * Constructs a Password4j password encoder with a custom hashing function. | 	 * </ul> | ||||||
| 	 * | 	 * | ||||||
| 	 * @param hashingFunction the custom hashing function to use | 	 * <p> | ||||||
| 	 * @param algorithm       the password hashing algorithm type | 	 * For custom configurations, you can create specific function instances: | ||||||
|  | 	 * </p> | ||||||
|  | 	 * <ul> | ||||||
|  | 	 * <li>{@code BcryptFunction.getInstance(12)} - BCrypt with 12 rounds</li> | ||||||
|  | 	 * <li>{@code Argon2Function.getInstance(65536, 3, 4, 32, Argon2.ID)} - Custom | ||||||
|  | 	 * Argon2</li> | ||||||
|  | 	 * <li>{@code ScryptFunction.getInstance(16384, 8, 1, 32)} - Custom SCrypt</li> | ||||||
|  | 	 * <li>{@code CompressedPBKDF2Function.getInstance("SHA256", 310000, 32)} - Custom | ||||||
|  | 	 * PBKDF2</li> | ||||||
|  | 	 * </ul> | ||||||
|  | 	 * @param hashingFunction the hashing function to use for encoding passwords, must not | ||||||
|  | 	 * be null | ||||||
|  | 	 * @throws IllegalArgumentException if hashingFunction is null | ||||||
| 	 */ | 	 */ | ||||||
| 	public Password4jPasswordEncoder(HashingFunction hashingFunction, Password4jAlgorithm algorithm) { | 	public Password4jPasswordEncoder(HashingFunction hashingFunction) { | ||||||
| 		Assert.notNull(hashingFunction, "hashingFunction cannot be null"); | 		Assert.notNull(hashingFunction, "hashingFunction cannot be null"); | ||||||
| 		Assert.notNull(algorithm, "algorithm cannot be null"); |  | ||||||
| 		this.hashingFunction = hashingFunction; | 		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 | 	@Override | ||||||
|  | @ -188,7 +110,8 @@ public class Password4jPasswordEncoder extends AbstractValidatingPasswordEncoder | ||||||
| 		try { | 		try { | ||||||
| 			Hash hash = Password.hash(rawPassword).with(this.hashingFunction); | 			Hash hash = Password.hash(rawPassword).with(this.hashingFunction); | ||||||
| 			return hash.getResult(); | 			return hash.getResult(); | ||||||
| 		} catch (Exception ex) { | 		} | ||||||
|  | 		catch (Exception ex) { | ||||||
| 			throw new IllegalStateException("Failed to encode password using Password4j", ex); | 			throw new IllegalStateException("Failed to encode password using Password4j", ex); | ||||||
| 		} | 		} | ||||||
| 	} | 	} | ||||||
|  | @ -198,7 +121,8 @@ public class Password4jPasswordEncoder extends AbstractValidatingPasswordEncoder | ||||||
| 		try { | 		try { | ||||||
| 			// Use the specific hashing function for verification | 			// Use the specific hashing function for verification | ||||||
| 			return Password.check(rawPassword, encodedPassword).with(this.hashingFunction); | 			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); | 			this.logger.warn("Password verification failed for encoded password: " + encodedPassword, ex); | ||||||
| 			return false; | 			return false; | ||||||
| 		} | 		} | ||||||
|  | @ -211,39 +135,4 @@ public class Password4jPasswordEncoder extends AbstractValidatingPasswordEncoder | ||||||
| 		return false; | 		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; |  | ||||||
| 	} |  | ||||||
| 
 |  | ||||||
| } | } | ||||||
|  |  | ||||||
|  | @ -14,7 +14,6 @@ | ||||||
|  * limitations under the License. |  * limitations under the License. | ||||||
|  */ |  */ | ||||||
| 
 | 
 | ||||||
| 
 |  | ||||||
| @NullMarked | @NullMarked | ||||||
| package org.springframework.security.crypto.password4j; | package org.springframework.security.crypto.password4j; | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -16,17 +16,10 @@ | ||||||
| 
 | 
 | ||||||
| package org.springframework.security.crypto.password4j; | package org.springframework.security.crypto.password4j; | ||||||
| 
 | 
 | ||||||
| import com.password4j.Argon2Function; | import com.password4j.AlgorithmFinder; | ||||||
| import com.password4j.BcryptFunction; | import com.password4j.BcryptFunction; | ||||||
| import com.password4j.CompressedPBKDF2Function; | import com.password4j.HashingFunction; | ||||||
| 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.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.assertThat; | ||||||
| import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; | import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; | ||||||
|  | @ -38,559 +31,111 @@ import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException | ||||||
|  */ |  */ | ||||||
| class Password4jPasswordEncoderTests { | class Password4jPasswordEncoderTests { | ||||||
| 
 | 
 | ||||||
|     private static final String PASSWORD = "password"; | 	private static final String PASSWORD = "password"; | ||||||
|     private static final String WRONG_PASSWORD = "wrongpassword"; | 
 | ||||||
|     private static final String UNICODE_PASSWORD = "пароль123🔐"; | 	private static final String WRONG_PASSWORD = "wrongpassword"; | ||||||
|     private static final String LONG_PASSWORD = "a".repeat(1000); | 
 | ||||||
|  | 	// 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(); | ||||||
|  | 	} | ||||||
| 
 | 
 | ||||||
|     // 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<String> hashes = new java.util.HashSet<>(); |  | ||||||
| 
 |  | ||||||
|         // Generate many hashes of the same password |  | ||||||
|         for (int i = 0; i < 100; i++) { |  | ||||||
|             String hash = encoder.encode(PASSWORD); |  | ||||||
|             assertThat(hashes.add(hash)).isTrue(); // Each hash should be unique |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         assertThat(hashes).hasSize(100); |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     @Test |  | ||||||
|     void shouldResistTimingAttacks() { |  | ||||||
|         Password4jPasswordEncoder encoder = Password4jPasswordEncoder.defaultsForSpringSecurity(); |  | ||||||
|         String validHash = encoder.encode(PASSWORD); |  | ||||||
| 
 |  | ||||||
|         // Measure time for correct password |  | ||||||
|         long startTime = System.nanoTime(); |  | ||||||
|         boolean result1 = encoder.matches(PASSWORD, validHash); |  | ||||||
|         long correctTime = System.nanoTime() - startTime; |  | ||||||
| 
 |  | ||||||
|         // Measure time for wrong password of same length |  | ||||||
|         startTime = System.nanoTime(); |  | ||||||
|         boolean result2 = encoder.matches("passwore", validHash); // Same length, different content |  | ||||||
|         long wrongTime = System.nanoTime() - startTime; |  | ||||||
| 
 |  | ||||||
|         assertThat(result1).isTrue(); |  | ||||||
|         assertThat(result2).isFalse(); |  | ||||||
| 
 |  | ||||||
|         // Times should be relatively close (within 10x factor for timing attack resistance) |  | ||||||
|         double ratio = Math.max(correctTime, wrongTime) / (double) Math.min(correctTime, wrongTime); |  | ||||||
|         assertThat(ratio).isLessThan(10.0); |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
|     @Test |  | ||||||
|     void scryptShouldHandleEdgeCaseParameters() { |  | ||||||
|         // Test with minimum viable parameters |  | ||||||
|         Password4jPasswordEncoder encoder = Password4jPasswordEncoder.scrypt(2, 1, 1, 16); |  | ||||||
| 
 |  | ||||||
|         String encoded = encoder.encode(PASSWORD); |  | ||||||
|         assertThat(encoder.matches(PASSWORD, encoded)).isTrue(); |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     @Test |  | ||||||
|     void argon2ShouldWorkWithDifferentMemorySizes() { |  | ||||||
|         // Test with various memory configurations |  | ||||||
|         int[] memorySizes = {1024, 4096, 16384, 65536}; |  | ||||||
| 
 |  | ||||||
|         for (int memory : memorySizes) { |  | ||||||
|             Password4jPasswordEncoder encoder = Password4jPasswordEncoder.argon2(memory, 2, 1, 32, Argon2.ID); |  | ||||||
|             String encoded = encoder.encode(PASSWORD); |  | ||||||
|             assertThat(encoder.matches(PASSWORD, encoded)).isTrue(); |  | ||||||
|         } |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     @Test |  | ||||||
|     void pbkdf2ShouldWorkWithDifferentHashAlgorithms() { |  | ||||||
|         // Test that the implementation handles different internal configurations |  | ||||||
|         Password4jPasswordEncoder encoder1 = Password4jPasswordEncoder.pbkdf2(50000, 16); |  | ||||||
|         Password4jPasswordEncoder encoder2 = Password4jPasswordEncoder.pbkdf2(100000, 32); |  | ||||||
|         Password4jPasswordEncoder encoder3 = Password4jPasswordEncoder.pbkdf2(200000, 64); |  | ||||||
| 
 |  | ||||||
|         String hash1 = encoder1.encode(PASSWORD); |  | ||||||
|         String hash2 = encoder2.encode(PASSWORD); |  | ||||||
|         String hash3 = encoder3.encode(PASSWORD); |  | ||||||
| 
 |  | ||||||
|         assertThat(encoder1.matches(PASSWORD, hash1)).isTrue(); |  | ||||||
|         assertThat(encoder2.matches(PASSWORD, hash2)).isTrue(); |  | ||||||
|         assertThat(encoder3.matches(PASSWORD, hash3)).isTrue(); |  | ||||||
| 
 |  | ||||||
|         // Hashes should be different due to different parameters |  | ||||||
|         assertThat(hash1).isNotEqualTo(hash2); |  | ||||||
|         assertThat(hash2).isNotEqualTo(hash3); |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     // Cross-Algorithm Verification Tests |  | ||||||
|     @Test |  | ||||||
|     void differentAlgorithmsShouldNotCrossVerify() { |  | ||||||
|         Password4jPasswordEncoder bcryptEncoder = Password4jPasswordEncoder.bcrypt(10); |  | ||||||
|         Password4jPasswordEncoder scryptEncoder = Password4jPasswordEncoder.scrypt(16384, 8, 1, 32); |  | ||||||
|         Password4jPasswordEncoder argon2Encoder = Password4jPasswordEncoder.argon2(65536, 3, 4, 32, Argon2.ID); |  | ||||||
| 
 |  | ||||||
|         String bcryptHash = bcryptEncoder.encode(PASSWORD); |  | ||||||
|         String scryptHash = scryptEncoder.encode(PASSWORD); |  | ||||||
|         String argon2Hash = argon2Encoder.encode(PASSWORD); |  | ||||||
| 
 |  | ||||||
|         // Each encoder should only verify its own hashes |  | ||||||
|         assertThat(bcryptEncoder.matches(PASSWORD, bcryptHash)).isTrue(); |  | ||||||
|         assertThat(bcryptEncoder.matches(PASSWORD, scryptHash)).isFalse(); |  | ||||||
|         assertThat(bcryptEncoder.matches(PASSWORD, argon2Hash)).isFalse(); |  | ||||||
| 
 |  | ||||||
|         assertThat(scryptEncoder.matches(PASSWORD, scryptHash)).isTrue(); |  | ||||||
|         assertThat(scryptEncoder.matches(PASSWORD, bcryptHash)).isFalse(); |  | ||||||
|         assertThat(scryptEncoder.matches(PASSWORD, argon2Hash)).isFalse(); |  | ||||||
| 
 |  | ||||||
|         assertThat(argon2Encoder.matches(PASSWORD, argon2Hash)).isTrue(); |  | ||||||
|         assertThat(argon2Encoder.matches(PASSWORD, bcryptHash)).isFalse(); |  | ||||||
|         assertThat(argon2Encoder.matches(PASSWORD, scryptHash)).isFalse(); |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
|     @Test |  | ||||||
|     void encodingShouldCompleteInReasonableTime() { |  | ||||||
|         Password4jPasswordEncoder encoder = Password4jPasswordEncoder.defaultsForSpringSecurity(); |  | ||||||
| 
 |  | ||||||
|         long startTime = System.currentTimeMillis(); |  | ||||||
|         String encoded = encoder.encode(PASSWORD); |  | ||||||
|         long duration = System.currentTimeMillis() - startTime; |  | ||||||
| 
 |  | ||||||
|         assertThat(encoded).isNotNull(); |  | ||||||
|         assertThat(duration).isLessThan(5000); // Should complete within 5 seconds |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     // Compatibility and Integration Tests |  | ||||||
|     @Test |  | ||||||
|     void shouldBeCompatibleWithSpringSecurityConventions() { |  | ||||||
|         Password4jPasswordEncoder encoder = Password4jPasswordEncoder.defaultsForSpringSecurity(); |  | ||||||
| 
 |  | ||||||
|         // Test common Spring Security patterns |  | ||||||
|         assertThat(encoder.encode(null)).isNull(); |  | ||||||
|         assertThat(encoder.matches(null, "hash")).isFalse(); |  | ||||||
|         assertThat(encoder.matches("password", null)).isFalse(); |  | ||||||
|         assertThat(encoder.upgradeEncoding("anyhash")).isFalse(); |  | ||||||
| 
 |  | ||||||
|         // Test that it follows AbstractValidatingPasswordEncoder contract |  | ||||||
|         assertThat(encoder.matches("", "")).isFalse(); |  | ||||||
|         assertThat(encoder.upgradeEncoding("")).isFalse(); |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     @Test |  | ||||||
|     void factoryMethodsShouldCreateCorrectInstances() { |  | ||||||
|         // Verify all factory methods create properly configured instances |  | ||||||
|         Password4jPasswordEncoder bcrypt = Password4jPasswordEncoder.bcrypt(12); |  | ||||||
|         assertThat(bcrypt.getAlgorithm()).isEqualTo(Password4jPasswordEncoder.Password4jAlgorithm.BCRYPT); |  | ||||||
|         assertThat(bcrypt.getHashingFunction()).isInstanceOf(BcryptFunction.class); |  | ||||||
| 
 |  | ||||||
|         Password4jPasswordEncoder scrypt = Password4jPasswordEncoder.scrypt(32768, 8, 1, 32); |  | ||||||
|         assertThat(scrypt.getAlgorithm()).isEqualTo(Password4jPasswordEncoder.Password4jAlgorithm.SCRYPT); |  | ||||||
|         assertThat(scrypt.getHashingFunction()).isInstanceOf(ScryptFunction.class); |  | ||||||
| 
 |  | ||||||
|         Password4jPasswordEncoder argon2 = Password4jPasswordEncoder.argon2(65536, 3, 4, 32, Argon2.ID); |  | ||||||
|         assertThat(argon2.getAlgorithm()).isEqualTo(Password4jPasswordEncoder.Password4jAlgorithm.ARGON2); |  | ||||||
|         assertThat(argon2.getHashingFunction()).isInstanceOf(Argon2Function.class); |  | ||||||
| 
 |  | ||||||
|         Password4jPasswordEncoder pbkdf2 = Password4jPasswordEncoder.pbkdf2(100000, 32); |  | ||||||
|         assertThat(pbkdf2.getAlgorithm()).isEqualTo(Password4jPasswordEncoder.Password4jAlgorithm.PBKDF2); |  | ||||||
|         assertThat(pbkdf2.getHashingFunction()).isInstanceOf(CompressedPBKDF2Function.class); |  | ||||||
| 
 |  | ||||||
|         Password4jPasswordEncoder compressedPbkdf2 = Password4jPasswordEncoder.compressedPbkdf2(100000, 32); |  | ||||||
|         assertThat(compressedPbkdf2.getAlgorithm()).isEqualTo(Password4jPasswordEncoder.Password4jAlgorithm.COMPRESSED_PBKDF2); |  | ||||||
|         assertThat(compressedPbkdf2.getHashingFunction()).isInstanceOf(CompressedPBKDF2Function.class); |  | ||||||
|     } |  | ||||||
| } | } | ||||||
|  |  | ||||||
|  | @ -13,6 +13,7 @@ org-jetbrains-kotlinx = "1.10.2" | ||||||
| org-mockito = "5.17.0" | org-mockito = "5.17.0" | ||||||
| org-opensaml5 = "5.1.6" | org-opensaml5 = "5.1.6" | ||||||
| org-springframework = "7.0.0-M9" | org-springframework = "7.0.0-M9" | ||||||
|  | com-password4j = "1.8.2" | ||||||
| 
 | 
 | ||||||
| [libraries] | [libraries] | ||||||
| ch-qos-logback-logback-classic = "ch.qos.logback:logback-classic:1.5.18" | 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' | spring-nullability = 'io.spring.nullability:io.spring.nullability.gradle.plugin:0.0.4' | ||||||
| webauthn4j-core = 'com.webauthn4j:webauthn4j-core:0.29.6.RELEASE' | webauthn4j-core = 'com.webauthn4j:webauthn4j-core:0.29.6.RELEASE' | ||||||
|  | com-password4j-password4j = { module = "com.password4j:password4j", version.ref = "com-password4j" } | ||||||
| 
 | 
 | ||||||
| [plugins] | [plugins] | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
		Loading…
	
		Reference in New Issue