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:
-13
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+68
-179
@@ -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;
|
||||||
|
|
||||||
|
|||||||
+108
-563
@@ -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]
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user