Add Argon2 and BCrypt and Scrypt password encoders using Password4j library

Closes gh-17706

Signed-off-by: Mehrdad <mehrdad.bozorgmehr@gmail.com>
Signed-off-by: M.Bozorgmehr <mehrdad.bozorgmehr@gmail.com>
This commit is contained in:
Mehrdad 2025-09-09 22:38:00 +03:30 committed by M.Bozorgmehr
parent 9f5d27e8d0
commit 8c2ad4e4d1
9 changed files with 1005 additions and 215 deletions

View File

@ -0,0 +1,74 @@
/*
* Copyright 2004-present the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.security.crypto.password4j;
import com.password4j.AlgorithmFinder;
import com.password4j.Argon2Function;
/**
* Implementation of {@link org.springframework.security.crypto.password.PasswordEncoder}
* that uses the Password4j library with Argon2 hashing algorithm.
*
* <p>
* Argon2 is the winner of the Password Hashing Competition (2015) and is recommended for
* new applications. It provides excellent resistance against GPU-based attacks and
* includes built-in salt generation. This implementation leverages Password4j's Argon2
* support which properly includes the salt in the output hash.
* </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 Argon2 settings (recommended)
* PasswordEncoder encoder = new Argon2Password4jPasswordEncoder();
*
* // Using custom Argon2 configuration
* PasswordEncoder customEncoder = new Argon2Password4jPasswordEncoder(
* Argon2Function.getInstance(65536, 3, 4, 32, Argon2.ID));
* }</pre>
*
* @author Mehrdad Bozorgmehr
* @since 7.0
* @see Argon2Function
* @see AlgorithmFinder#getArgon2Instance()
*/
public class Argon2Password4jPasswordEncoder extends Password4jPasswordEncoder {
/**
* Constructs an Argon2 password encoder using the default Argon2 configuration from
* Password4j's AlgorithmFinder.
*/
public Argon2Password4jPasswordEncoder() {
super(AlgorithmFinder.getArgon2Instance());
}
/**
* Constructs an Argon2 password encoder with a custom Argon2 function.
* @param argon2Function the Argon2 function to use for encoding passwords, must not
* be null
* @throws IllegalArgumentException if argon2Function is null
*/
public Argon2Password4jPasswordEncoder(Argon2Function argon2Function) {
super(argon2Function);
}
}

View File

@ -0,0 +1,72 @@
/*
* Copyright 2004-present the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.security.crypto.password4j;
import com.password4j.AlgorithmFinder;
import com.password4j.BcryptFunction;
/**
* Implementation of {@link org.springframework.security.crypto.password.PasswordEncoder}
* that uses the Password4j library with BCrypt hashing algorithm.
*
* <p>
* BCrypt is a well-established password hashing algorithm that includes built-in salt
* generation and is resistant to rainbow table attacks. This implementation leverages
* Password4j's BCrypt support which properly includes the salt in the output hash.
* </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 BCrypt settings (recommended)
* PasswordEncoder encoder = new BcryptPassword4jPasswordEncoder();
*
* // Using custom round count
* PasswordEncoder customEncoder = new BcryptPassword4jPasswordEncoder(BcryptFunction.getInstance(12));
* }</pre>
*
* @author Mehrdad Bozorgmehr
* @since 7.0
* @see BcryptFunction
* @see AlgorithmFinder#getBcryptInstance()
*/
public class BcryptPassword4jPasswordEncoder extends Password4jPasswordEncoder {
/**
* Constructs a BCrypt password encoder using the default BCrypt configuration from
* Password4j's AlgorithmFinder.
*/
public BcryptPassword4jPasswordEncoder() {
super(AlgorithmFinder.getBcryptInstance());
}
/**
* Constructs a BCrypt password encoder with a custom BCrypt function.
* @param bcryptFunction the BCrypt function to use for encoding passwords, must not
* be null
* @throws IllegalArgumentException if bcryptFunction is null
*/
public BcryptPassword4jPasswordEncoder(BcryptFunction bcryptFunction) {
super(bcryptFunction);
}
}

View File

@ -16,117 +16,57 @@
package org.springframework.security.crypto.password4j;
import com.password4j.AlgorithmFinder;
import com.password4j.Hash;
import com.password4j.HashingFunction;
import com.password4j.Password;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.springframework.security.crypto.password.AbstractValidatingPasswordEncoder;
import org.springframework.util.Assert;
/**
* Implementation of {@link org.springframework.security.crypto.password.PasswordEncoder}
* that uses the Password4j library. This encoder supports multiple password hashing
* algorithms including BCrypt, SCrypt, Argon2, and PBKDF2.
* Abstract base class for Password4j-based password encoders. This class provides the
* common functionality for password encoding and verification using the Password4j
* library.
*
* <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.
* This class is package-private and should not be used directly. Instead, use the
* specific public subclasses that support verified hashing algorithms such as BCrypt,
* Argon2, and SCrypt implementations.
* </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
* @since 7.0
* @see AlgorithmFinder
*/
public class Password4jPasswordEncoder extends AbstractValidatingPasswordEncoder {
private final Log logger = LogFactory.getLog(getClass());
abstract class Password4jPasswordEncoder extends AbstractValidatingPasswordEncoder {
private final HashingFunction hashingFunction;
/**
* Constructs a Password4j password encoder with the specified hashing function.
*
* <p>
* It is recommended to use password4j's {@link AlgorithmFinder} to obtain default
* instances with secure configurations:
* </p>
* <ul>
* <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>
* </ul>
*
* <p>
* 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>
* Constructs a Password4j password encoder with the specified hashing function. This
* constructor is package-private and intended for use by subclasses only.
* @param hashingFunction the hashing function to use for encoding passwords, must not
* be null
* @throws IllegalArgumentException if hashingFunction is null
*/
public Password4jPasswordEncoder(HashingFunction hashingFunction) {
Password4jPasswordEncoder(HashingFunction hashingFunction) {
Assert.notNull(hashingFunction, "hashingFunction cannot be null");
this.hashingFunction = hashingFunction;
}
@Override
protected String encodeNonNullPassword(String rawPassword) {
try {
Hash hash = Password.hash(rawPassword).with(this.hashingFunction);
return hash.getResult();
}
catch (Exception ex) {
throw new IllegalStateException("Failed to encode password using Password4j", ex);
}
}
@Override
protected boolean matchesNonNull(String rawPassword, String encodedPassword) {
try {
// Use the specific hashing function for verification
return Password.check(rawPassword, encodedPassword).with(this.hashingFunction);
}
catch (Exception ex) {
this.logger.warn("Password verification failed for encoded password: " + encodedPassword, ex);
return false;
}
}
@Override
protected boolean upgradeEncodingNonNull(String encodedPassword) {

View File

@ -0,0 +1,74 @@
/*
* Copyright 2004-present the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.security.crypto.password4j;
import com.password4j.AlgorithmFinder;
import com.password4j.ScryptFunction;
/**
* Implementation of {@link org.springframework.security.crypto.password.PasswordEncoder}
* that uses the Password4j library with SCrypt hashing algorithm.
*
* <p>
* SCrypt is a memory-hard password hashing algorithm designed to be resistant to hardware
* brute-force attacks. It includes built-in salt generation and is particularly effective
* against ASIC and GPU-based attacks. This implementation leverages Password4j's SCrypt
* support which properly includes the salt in the output hash.
* </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 SCrypt settings (recommended)
* PasswordEncoder encoder = new ScryptPassword4jPasswordEncoder();
*
* // Using custom SCrypt configuration
* PasswordEncoder customEncoder = new ScryptPassword4jPasswordEncoder(
* ScryptFunction.getInstance(32768, 8, 1, 32));
* }</pre>
*
* @author Mehrdad Bozorgmehr
* @since 7.0
* @see ScryptFunction
* @see AlgorithmFinder#getScryptInstance()
*/
public class ScryptPassword4jPasswordEncoder extends Password4jPasswordEncoder {
/**
* Constructs an SCrypt password encoder using the default SCrypt configuration from
* Password4j's AlgorithmFinder.
*/
public ScryptPassword4jPasswordEncoder() {
super(AlgorithmFinder.getScryptInstance());
}
/**
* Constructs an SCrypt password encoder with a custom SCrypt function.
* @param scryptFunction the SCrypt function to use for encoding passwords, must not
* be null
* @throws IllegalArgumentException if scryptFunction is null
*/
public ScryptPassword4jPasswordEncoder(ScryptFunction scryptFunction) {
super(scryptFunction);
}
}

View File

@ -0,0 +1,245 @@
/*
* Copyright 2004-present the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.security.crypto.password4j;
import com.password4j.AlgorithmFinder;
import com.password4j.Argon2Function;
import com.password4j.types.Argon2;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.EnumSource;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
/**
* Tests for {@link Argon2Password4jPasswordEncoder}.
*
* @author Mehrdad Bozorgmehr
*/
class Argon2Password4jPasswordEncoderTests {
private static final String PASSWORD = "password";
private static final String LONG_PASSWORD = "a".repeat(1000);
private static final String SPECIAL_CHARS_PASSWORD = "p@ssw0rd!#$%^&*()_+-=[]{}|;':\",./<>?";
private static final String UNICODE_PASSWORD = "пароль密码パスワード🔐";
@Test
void defaultConstructorShouldCreateWorkingEncoder() {
Argon2Password4jPasswordEncoder encoder = new Argon2Password4jPasswordEncoder();
String encoded = encoder.encode(PASSWORD);
assertThat(encoded).isNotNull();
assertThat(encoded).startsWith("$argon2"); // Argon2 hash format
assertThat(encoder.matches(PASSWORD, encoded)).isTrue();
}
@Test
void constructorWithNullArgon2FunctionShouldThrowException() {
assertThatIllegalArgumentException().isThrownBy(() -> new Argon2Password4jPasswordEncoder(null))
.withMessage("hashingFunction cannot be null");
}
@Test
void constructorWithCustomArgon2FunctionShouldWork() {
Argon2Function customFunction = Argon2Function.getInstance(4096, 3, 1, 32, Argon2.ID);
Argon2Password4jPasswordEncoder encoder = new Argon2Password4jPasswordEncoder(customFunction);
String encoded = encoder.encode(PASSWORD);
assertThat(encoded).isNotNull();
assertThat(encoded).startsWith("$argon2id");
assertThat(encoder.matches(PASSWORD, encoded)).isTrue();
}
@ParameterizedTest
@EnumSource(Argon2.class)
void encodingShouldWorkWithDifferentArgon2Types(Argon2 type) {
Argon2Function function = Argon2Function.getInstance(4096, 3, 1, 32, type);
Argon2Password4jPasswordEncoder encoder = new Argon2Password4jPasswordEncoder(function);
String encoded = encoder.encode(PASSWORD);
assertThat(encoded).isNotNull();
assertThat(encoded).startsWith("$argon2" + type.name().toLowerCase());
assertThat(encoder.matches(PASSWORD, encoded)).isTrue();
}
@Test
void encodingShouldGenerateDifferentHashesForSamePassword() {
Argon2Password4jPasswordEncoder encoder = new Argon2Password4jPasswordEncoder();
String hash1 = encoder.encode(PASSWORD);
String hash2 = encoder.encode(PASSWORD);
assertThat(hash1).isNotEqualTo(hash2);
assertThat(encoder.matches(PASSWORD, hash1)).isTrue();
assertThat(encoder.matches(PASSWORD, hash2)).isTrue();
}
@Test
void shouldHandleLongPasswords() {
Argon2Password4jPasswordEncoder encoder = new Argon2Password4jPasswordEncoder();
String encoded = encoder.encode(LONG_PASSWORD);
assertThat(encoder.matches(LONG_PASSWORD, encoded)).isTrue();
assertThat(encoder.matches("wrong", encoded)).isFalse();
}
@Test
void shouldHandleSpecialCharacters() {
Argon2Password4jPasswordEncoder encoder = new Argon2Password4jPasswordEncoder();
String encoded = encoder.encode(SPECIAL_CHARS_PASSWORD);
assertThat(encoder.matches(SPECIAL_CHARS_PASSWORD, encoded)).isTrue();
assertThat(encoder.matches("wrong", encoded)).isFalse();
}
@Test
void shouldHandleUnicodeCharacters() {
Argon2Password4jPasswordEncoder encoder = new Argon2Password4jPasswordEncoder();
String encoded = encoder.encode(UNICODE_PASSWORD);
assertThat(encoder.matches(UNICODE_PASSWORD, encoded)).isTrue();
assertThat(encoder.matches("wrong", encoded)).isFalse();
}
@Test
void shouldRejectIncorrectPasswords() {
Argon2Password4jPasswordEncoder encoder = new Argon2Password4jPasswordEncoder();
String encoded = encoder.encode(PASSWORD);
assertThat(encoder.matches("wrongpassword", encoded)).isFalse();
assertThat(encoder.matches("PASSWORD", encoded)).isFalse(); // Case sensitive
assertThat(encoder.matches("password ", encoded)).isFalse(); // Trailing space
assertThat(encoder.matches(" password", encoded)).isFalse(); // Leading space
}
@Test
void matchesShouldReturnFalseForNullOrEmptyInputs() {
Argon2Password4jPasswordEncoder encoder = new Argon2Password4jPasswordEncoder();
String encoded = encoder.encode(PASSWORD);
assertThat(encoder.matches(null, encoded)).isFalse();
assertThat(encoder.matches("", encoded)).isFalse();
assertThat(encoder.matches(PASSWORD, null)).isFalse();
assertThat(encoder.matches(PASSWORD, "")).isFalse();
assertThat(encoder.matches(null, null)).isFalse();
assertThat(encoder.matches("", "")).isFalse();
}
@Test
void encodeNullShouldReturnNull() {
Argon2Password4jPasswordEncoder encoder = new Argon2Password4jPasswordEncoder();
assertThat(encoder.encode(null)).isNull();
}
@Test
void upgradeEncodingShouldReturnFalse() {
Argon2Password4jPasswordEncoder encoder = new Argon2Password4jPasswordEncoder();
String encoded = encoder.encode(PASSWORD);
assertThat(encoder.upgradeEncoding(encoded)).isFalse();
}
@Test
void shouldWorkWithAlgorithmFinderDefaults() {
Argon2Password4jPasswordEncoder encoder = new Argon2Password4jPasswordEncoder(
AlgorithmFinder.getArgon2Instance());
String encoded = encoder.encode(PASSWORD);
assertThat(encoded).isNotNull();
assertThat(encoder.matches(PASSWORD, encoded)).isTrue();
}
@Test
void shouldRejectMalformedHashes() {
Argon2Password4jPasswordEncoder encoder = new Argon2Password4jPasswordEncoder();
// For Argon2, Password4j may throw BadParametersException on malformed hashes.
// We treat either an exception or a false return as a successful rejection.
assertMalformedRejected(encoder, PASSWORD, "invalid_hash");
assertMalformedRejected(encoder, PASSWORD, "$argon2id$invalid");
assertMalformedRejected(encoder, PASSWORD, "");
}
private void assertMalformedRejected(Argon2Password4jPasswordEncoder encoder, String raw, String malformed) {
boolean rejected = false;
try {
rejected = !encoder.matches(raw, malformed);
}
catch (RuntimeException ex) {
// Accept exception as valid rejection path for malformed input
rejected = true;
}
assertThat(rejected).as("Malformed hash should not validate: " + malformed).isTrue();
}
@Test
void shouldHandleEmptyStringPassword() {
Argon2Password4jPasswordEncoder encoder = new Argon2Password4jPasswordEncoder();
String encoded = encoder.encode("");
assertThat(encoded).isNotNull();
boolean emptyStringMatches;
try {
emptyStringMatches = encoder.matches("", encoded);
}
catch (RuntimeException ex) {
emptyStringMatches = false; // treat exception as non-match but still
// acceptable behavior
}
if (emptyStringMatches) {
assertThat(encoder.matches("", encoded)).isTrue();
}
else {
assertThat(encoded).isNotEmpty();
}
assertThat(encoder.matches("notEmpty", encoded)).isFalse();
}
@Test
void shouldHandleCustomMemoryAndIterationParameters() {
// Test with different memory and iteration parameters
Argon2Function lowMemory = Argon2Function.getInstance(1024, 2, 1, 16, Argon2.ID);
Argon2Function highMemory = Argon2Function.getInstance(65536, 4, 2, 64, Argon2.ID);
Argon2Password4jPasswordEncoder lowEncoder = new Argon2Password4jPasswordEncoder(lowMemory);
Argon2Password4jPasswordEncoder highEncoder = new Argon2Password4jPasswordEncoder(highMemory);
String lowEncoded = lowEncoder.encode(PASSWORD);
String highEncoded = highEncoder.encode(PASSWORD);
assertThat(lowEncoder.matches(PASSWORD, lowEncoded)).isTrue();
assertThat(highEncoder.matches(PASSWORD, highEncoded)).isTrue();
// Each encoder should work with hashes generated by the same parameters
assertThat(lowEncoded).isNotEqualTo(highEncoded);
}
}

View File

@ -0,0 +1,217 @@
/*
* Copyright 2004-present the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.security.crypto.password4j;
import com.password4j.AlgorithmFinder;
import com.password4j.BcryptFunction;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
/**
* Tests for {@link BcryptPassword4jPasswordEncoder}.
*
* @author Mehrdad Bozorgmehr
*/
class BcryptPassword4jPasswordEncoderTests {
private static final String PASSWORD = "password";
private static final String LONG_PASSWORD = "a".repeat(72); // BCrypt max length
private static final String VERY_LONG_PASSWORD = "a".repeat(100); // Beyond BCrypt max
// length
private static final String SPECIAL_CHARS_PASSWORD = "p@ssw0rd!#$%^&*()_+-=[]{}|;':\",./<>?";
private static final String UNICODE_PASSWORD = "пароль密码パスワード🔐";
@Test
void defaultConstructorShouldCreateWorkingEncoder() {
BcryptPassword4jPasswordEncoder encoder = new BcryptPassword4jPasswordEncoder();
String encoded = encoder.encode(PASSWORD);
assertThat(encoded).isNotNull().matches("^\\$2[aby]?\\$.*");
assertThat(encoder.matches(PASSWORD, encoded)).isTrue();
}
@Test
void constructorWithNullBcryptFunctionShouldThrowException() {
assertThatIllegalArgumentException().isThrownBy(() -> new BcryptPassword4jPasswordEncoder(null))
.withMessage("hashingFunction cannot be null");
}
@Test
void constructorWithCustomBcryptFunctionShouldWork() {
BcryptFunction customFunction = BcryptFunction.getInstance(6);
BcryptPassword4jPasswordEncoder encoder = new BcryptPassword4jPasswordEncoder(customFunction);
String encoded = encoder.encode(PASSWORD);
assertThat(encoded).isNotNull().contains("$06$"); // 6 rounds
assertThat(encoder.matches(PASSWORD, encoded)).isTrue();
}
@ParameterizedTest
@ValueSource(ints = { 4, 6, 8, 10, 12 })
void encodingShouldWorkWithDifferentRounds(int rounds) {
BcryptFunction function = BcryptFunction.getInstance(rounds);
BcryptPassword4jPasswordEncoder encoder = new BcryptPassword4jPasswordEncoder(function);
String encoded = encoder.encode(PASSWORD);
assertThat(encoded).isNotNull().contains(String.format("$%02d$", rounds));
assertThat(encoder.matches(PASSWORD, encoded)).isTrue();
}
@Test
void encodingShouldGenerateDifferentHashesForSamePassword() {
BcryptPassword4jPasswordEncoder encoder = new BcryptPassword4jPasswordEncoder();
String hash1 = encoder.encode(PASSWORD);
String hash2 = encoder.encode(PASSWORD);
assertThat(hash1).isNotEqualTo(hash2);
assertThat(encoder.matches(PASSWORD, hash1)).isTrue();
assertThat(encoder.matches(PASSWORD, hash2)).isTrue();
}
@Test
void shouldHandleLongPasswords() {
BcryptPassword4jPasswordEncoder encoder = new BcryptPassword4jPasswordEncoder();
String encodedLong = encoder.encode(LONG_PASSWORD);
String encodedVeryLong = encoder.encode(VERY_LONG_PASSWORD);
assertThat(encoder.matches(LONG_PASSWORD, encodedLong)).isTrue();
assertThat(encoder.matches(VERY_LONG_PASSWORD, encodedVeryLong)).isTrue();
}
@Test
void shouldHandleSpecialCharacters() {
BcryptPassword4jPasswordEncoder encoder = new BcryptPassword4jPasswordEncoder();
String encoded = encoder.encode(SPECIAL_CHARS_PASSWORD);
assertThat(encoder.matches(SPECIAL_CHARS_PASSWORD, encoded)).isTrue();
assertThat(encoder.matches("wrong", encoded)).isFalse();
}
@Test
void shouldHandleUnicodeCharacters() {
BcryptPassword4jPasswordEncoder encoder = new BcryptPassword4jPasswordEncoder();
String encoded = encoder.encode(UNICODE_PASSWORD);
assertThat(encoder.matches(UNICODE_PASSWORD, encoded)).isTrue();
assertThat(encoder.matches("wrong", encoded)).isFalse();
}
@Test
void shouldRejectIncorrectPasswords() {
BcryptPassword4jPasswordEncoder encoder = new BcryptPassword4jPasswordEncoder();
String encoded = encoder.encode(PASSWORD);
assertThat(encoder.matches("wrongpassword", encoded)).isFalse();
assertThat(encoder.matches("PASSWORD", encoded)).isFalse(); // Case sensitive
assertThat(encoder.matches("password ", encoded)).isFalse(); // Trailing space
assertThat(encoder.matches(" password", encoded)).isFalse(); // Leading space
}
@Test
void matchesShouldReturnFalseForNullOrEmptyInputs() {
BcryptPassword4jPasswordEncoder encoder = new BcryptPassword4jPasswordEncoder();
String encoded = encoder.encode(PASSWORD);
assertThat(encoder.matches(null, encoded)).isFalse();
assertThat(encoder.matches("", encoded)).isFalse();
assertThat(encoder.matches(PASSWORD, null)).isFalse();
assertThat(encoder.matches(PASSWORD, "")).isFalse();
assertThat(encoder.matches(null, null)).isFalse();
assertThat(encoder.matches("", "")).isFalse();
}
@Test
void encodeNullShouldReturnNull() {
BcryptPassword4jPasswordEncoder encoder = new BcryptPassword4jPasswordEncoder();
assertThat(encoder.encode(null)).isNull();
}
@Test
void upgradeEncodingShouldReturnFalse() {
BcryptPassword4jPasswordEncoder encoder = new BcryptPassword4jPasswordEncoder();
String encoded = encoder.encode(PASSWORD);
assertThat(encoder.upgradeEncoding(encoded)).isFalse();
}
@Test
void shouldWorkWithAlgorithmFinderDefaults() {
BcryptPassword4jPasswordEncoder encoder = new BcryptPassword4jPasswordEncoder(
AlgorithmFinder.getBcryptInstance());
String encoded = encoder.encode(PASSWORD);
assertThat(encoded).isNotNull();
assertThat(encoder.matches(PASSWORD, encoded)).isTrue();
}
@Test
void shouldRejectMalformedHashes() {
BcryptPassword4jPasswordEncoder encoder = new BcryptPassword4jPasswordEncoder();
assertMalformedRejected(encoder, PASSWORD, "invalid_hash");
assertMalformedRejected(encoder, PASSWORD, "$2a$10$invalid");
assertMalformedRejected(encoder, PASSWORD, "");
}
private void assertMalformedRejected(BcryptPassword4jPasswordEncoder encoder, String raw, String malformed) {
boolean rejected;
try {
rejected = !encoder.matches(raw, malformed);
}
catch (RuntimeException ex) {
rejected = true; // exception is acceptable rejection
}
assertThat(rejected).as("Malformed hash should not validate: " + malformed).isTrue();
}
@Test
void shouldHandleEmptyStringPassword() {
BcryptPassword4jPasswordEncoder encoder = new BcryptPassword4jPasswordEncoder();
String encoded = encoder.encode("");
assertThat(encoded).isNotNull();
boolean emptyMatches;
try {
emptyMatches = encoder.matches("", encoded);
}
catch (RuntimeException ex) {
emptyMatches = false; // treat as non-match if library rejects empty raw
}
// Either behavior acceptable; if it matches, verify; if not, still ensure other
// mismatches remain false.
if (emptyMatches) {
assertThat(encoder.matches("", encoded)).isTrue();
}
assertThat(encoder.matches("notEmpty", encoded)).isFalse();
}
}

View File

@ -16,16 +16,14 @@
package org.springframework.security.crypto.password4j;
import com.password4j.AlgorithmFinder;
import com.password4j.BcryptFunction;
import com.password4j.HashingFunction;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
/**
* Tests for {@link Password4jPasswordEncoder}.
* Base functionality tests for {@link Password4jPasswordEncoder} implementations. These
* tests verify the common behavior across all concrete password encoder subclasses.
*
* @author Mehrdad Bozorgmehr
*/
@ -35,27 +33,10 @@ class Password4jPasswordEncoderTests {
private static final String WRONG_PASSWORD = "wrongpassword";
// Constructor Tests
@Test
void constructorWithNullHashingFunctionShouldThrowException() {
assertThatIllegalArgumentException().isThrownBy(() -> new Password4jPasswordEncoder(null))
.withMessage("hashingFunction cannot be null");
}
@Test
void constructorWithValidHashingFunctionShouldWork() {
HashingFunction hashingFunction = BcryptFunction.getInstance(10);
Password4jPasswordEncoder encoder = new Password4jPasswordEncoder(hashingFunction);
assertThat(encoder).isNotNull();
}
// Basic functionality tests with real HashingFunction instances
// Test abstract class behavior through concrete implementation
@Test
void encodeShouldReturnNonNullHashedPassword() {
HashingFunction hashingFunction = BcryptFunction.getInstance(4); // Use low cost
// for faster
// tests
Password4jPasswordEncoder encoder = new Password4jPasswordEncoder(hashingFunction);
BcryptPassword4jPasswordEncoder encoder = new BcryptPassword4jPasswordEncoder(BcryptFunction.getInstance(4));
String result = encoder.encode(PASSWORD);
@ -64,10 +45,7 @@ class Password4jPasswordEncoderTests {
@Test
void matchesShouldReturnTrueForValidPassword() {
HashingFunction hashingFunction = BcryptFunction.getInstance(4); // Use low cost
// for faster
// tests
Password4jPasswordEncoder encoder = new Password4jPasswordEncoder(hashingFunction);
BcryptPassword4jPasswordEncoder encoder = new BcryptPassword4jPasswordEncoder(BcryptFunction.getInstance(4));
String encoded = encoder.encode(PASSWORD);
boolean result = encoder.matches(PASSWORD, encoded);
@ -77,10 +55,7 @@ class Password4jPasswordEncoderTests {
@Test
void matchesShouldReturnFalseForInvalidPassword() {
HashingFunction hashingFunction = BcryptFunction.getInstance(4); // Use low cost
// for faster
// tests
Password4jPasswordEncoder encoder = new Password4jPasswordEncoder(hashingFunction);
BcryptPassword4jPasswordEncoder encoder = new BcryptPassword4jPasswordEncoder(BcryptFunction.getInstance(4));
String encoded = encoder.encode(PASSWORD);
boolean result = encoder.matches(WRONG_PASSWORD, encoded);
@ -89,20 +64,27 @@ class Password4jPasswordEncoderTests {
}
@Test
void matchesShouldReturnFalseForMalformedHash() {
HashingFunction hashingFunction = BcryptFunction.getInstance(4);
Password4jPasswordEncoder encoder = new Password4jPasswordEncoder(hashingFunction);
void encodeNullPasswordShouldReturnNull() {
BcryptPassword4jPasswordEncoder encoder = new BcryptPassword4jPasswordEncoder(BcryptFunction.getInstance(4));
// Test with malformed hash that should cause Password4j to throw an exception
boolean result = encoder.matches(PASSWORD, "invalid-hash-format");
assertThat(encoder.encode(null)).isNull();
}
assertThat(result).isFalse();
@Test
void multipleEncodesProduceDifferentHashesButAllMatch() {
BcryptPassword4jPasswordEncoder encoder = new BcryptPassword4jPasswordEncoder(BcryptFunction.getInstance(4));
String encoded1 = encoder.encode(PASSWORD);
String encoded2 = encoder.encode(PASSWORD);
// Bcrypt should produce different salted hashes for the same raw password
assertThat(encoded1).isNotEqualTo(encoded2);
assertThat(encoder.matches(PASSWORD, encoded1)).isTrue();
assertThat(encoder.matches(PASSWORD, encoded2)).isTrue();
}
@Test
void upgradeEncodingShouldReturnFalse() {
HashingFunction hashingFunction = BcryptFunction.getInstance(4);
Password4jPasswordEncoder encoder = new Password4jPasswordEncoder(hashingFunction);
BcryptPassword4jPasswordEncoder encoder = new BcryptPassword4jPasswordEncoder(BcryptFunction.getInstance(4));
String encoded = encoder.encode(PASSWORD);
boolean result = encoder.upgradeEncoding(encoded);
@ -110,32 +92,14 @@ class Password4jPasswordEncoderTests {
assertThat(result).isFalse();
}
// AlgorithmFinder Sanity Check Tests
@Test
void algorithmFinderBcryptSanityCheck() {
Password4jPasswordEncoder encoder = new Password4jPasswordEncoder(AlgorithmFinder.getBcryptInstance());
void matchesShouldReturnFalseWhenRawOrEncodedNullOrEmpty() {
BcryptPassword4jPasswordEncoder encoder = new BcryptPassword4jPasswordEncoder(BcryptFunction.getInstance(4));
String encoded = encoder.encode(PASSWORD);
assertThat(encoder.matches(PASSWORD, encoded)).isTrue();
assertThat(encoder.matches(WRONG_PASSWORD, encoded)).isFalse();
}
@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();
assertThat(encoder.matches(null, encoded)).isFalse();
assertThat(encoder.matches("", encoded)).isFalse();
assertThat(encoder.matches(PASSWORD, null)).isFalse();
assertThat(encoder.matches(PASSWORD, "")).isFalse();
}
}

View File

@ -16,24 +16,21 @@
package org.springframework.security.crypto.password4j;
import com.password4j.AlgorithmFinder;
import com.password4j.Argon2Function;
import com.password4j.BcryptFunction;
import com.password4j.CompressedPBKDF2Function;
import com.password4j.ScryptFunction;
import com.password4j.types.Argon2;
import org.junit.jupiter.api.Test;
import org.springframework.security.crypto.argon2.Argon2PasswordEncoder;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.Pbkdf2PasswordEncoder;
import org.springframework.security.crypto.scrypt.SCryptPasswordEncoder;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Tests compatibility between existing Spring Security password encoders and
* {@link Password4jPasswordEncoder}.
* Password4j-based password encoders.
*
* @author Mehrdad Bozorgmehr
*/
@ -45,7 +42,8 @@ class PasswordCompatibilityTests {
@Test
void bcryptEncodedWithSpringSecurityShouldMatchWithPassword4j() {
BCryptPasswordEncoder springEncoder = new BCryptPasswordEncoder(10);
Password4jPasswordEncoder password4jEncoder = new Password4jPasswordEncoder(BcryptFunction.getInstance(10));
BcryptPassword4jPasswordEncoder password4jEncoder = new BcryptPassword4jPasswordEncoder(
BcryptFunction.getInstance(10));
String encodedBySpring = springEncoder.encode(PASSWORD);
boolean matchedByPassword4j = password4jEncoder.matches(PASSWORD, encodedBySpring);
@ -54,9 +52,35 @@ class PasswordCompatibilityTests {
}
@Test
void bcryptEncodedWithPassword4jShouldMatchWithSpringSecurity() {
void bcryptEncodedWithPassword4jShouldMatchWithSpringSecirity() {
BcryptPassword4jPasswordEncoder password4jEncoder = new BcryptPassword4jPasswordEncoder(
BcryptFunction.getInstance(10));
BCryptPasswordEncoder springEncoder = new BCryptPasswordEncoder(10);
Password4jPasswordEncoder password4jEncoder = new Password4jPasswordEncoder(BcryptFunction.getInstance(10));
String encodedByPassword4j = password4jEncoder.encode(PASSWORD);
boolean matchedBySpring = springEncoder.matches(PASSWORD, encodedByPassword4j);
assertThat(matchedBySpring).isTrue();
}
// Argon2 Compatibility Tests
@Test
void argon2EncodedWithSpringSecurityShouldMatchWithPassword4j() {
Argon2PasswordEncoder springEncoder = new Argon2PasswordEncoder(16, 32, 1, 4096, 3);
Argon2Password4jPasswordEncoder password4jEncoder = new Argon2Password4jPasswordEncoder(
Argon2Function.getInstance(4096, 3, 1, 32, Argon2.ID));
String encodedBySpring = springEncoder.encode(PASSWORD);
boolean matchedByPassword4j = password4jEncoder.matches(PASSWORD, encodedBySpring);
assertThat(matchedByPassword4j).isTrue();
}
@Test
void argon2EncodedWithPassword4jShouldMatchWithSpringSecirity() {
Argon2Password4jPasswordEncoder password4jEncoder = new Argon2Password4jPasswordEncoder(
Argon2Function.getInstance(4096, 3, 1, 32, Argon2.ID));
Argon2PasswordEncoder springEncoder = new Argon2PasswordEncoder(16, 32, 1, 4096, 3);
String encodedByPassword4j = password4jEncoder.encode(PASSWORD);
boolean matchedBySpring = springEncoder.matches(PASSWORD, encodedByPassword4j);
@ -68,7 +92,7 @@ class PasswordCompatibilityTests {
@Test
void scryptEncodedWithSpringSecurityShouldMatchWithPassword4j() {
SCryptPasswordEncoder springEncoder = new SCryptPasswordEncoder(16384, 8, 1, 32, 64);
Password4jPasswordEncoder password4jEncoder = new Password4jPasswordEncoder(
ScryptPassword4jPasswordEncoder password4jEncoder = new ScryptPassword4jPasswordEncoder(
ScryptFunction.getInstance(16384, 8, 1, 32));
String encodedBySpring = springEncoder.encode(PASSWORD);
@ -78,10 +102,10 @@ class PasswordCompatibilityTests {
}
@Test
void scryptEncodedWithPassword4jShouldMatchWithSpringSecurity() {
void scryptEncodedWithPassword4jShouldMatchWithSpringSecirity() {
ScryptPassword4jPasswordEncoder password4jEncoder = new ScryptPassword4jPasswordEncoder(
ScryptFunction.getInstance(16384, 8, 1, 32));
SCryptPasswordEncoder springEncoder = new SCryptPasswordEncoder(16384, 8, 1, 32, 64);
Password4jPasswordEncoder password4jEncoder = new Password4jPasswordEncoder(
ScryptFunction.getInstance(16384, 8, 1, 32));
String encodedByPassword4j = password4jEncoder.encode(PASSWORD);
boolean matchedBySpring = springEncoder.matches(PASSWORD, encodedByPassword4j);
@ -89,72 +113,4 @@ class PasswordCompatibilityTests {
assertThat(matchedBySpring).isTrue();
}
// Argon2 Compatibility Tests
@Test
void argon2EncodedWithSpringSecurityShouldMatchWithPassword4j() {
Argon2PasswordEncoder springEncoder = new Argon2PasswordEncoder(16, 32, 1, 65536, 3);
Password4jPasswordEncoder password4jEncoder = new Password4jPasswordEncoder(
Argon2Function.getInstance(65536, 3, 1, 32, Argon2.ID));
String encodedBySpring = springEncoder.encode(PASSWORD);
boolean matchedByPassword4j = password4jEncoder.matches(PASSWORD, encodedBySpring);
assertThat(matchedByPassword4j).isTrue();
}
@Test
void argon2EncodedWithPassword4jShouldMatchWithSpringSecurity() {
Argon2PasswordEncoder springEncoder = new Argon2PasswordEncoder(16, 32, 1, 65536, 3);
Password4jPasswordEncoder password4jEncoder = new Password4jPasswordEncoder(
Argon2Function.getInstance(65536, 3, 1, 32, Argon2.ID));
String encodedByPassword4j = password4jEncoder.encode(PASSWORD);
boolean matchedBySpring = springEncoder.matches(PASSWORD, encodedByPassword4j);
assertThat(matchedBySpring).isTrue();
}
// PBKDF2 Compatibility Tests - Note: Different format implementations
@Test
void pbkdf2BasicFunctionalityTest() {
// Test that both encoders work independently with their own formats
// Spring Security PBKDF2
Pbkdf2PasswordEncoder springEncoder = new Pbkdf2PasswordEncoder("", 16, 100000,
Pbkdf2PasswordEncoder.SecretKeyFactoryAlgorithm.PBKDF2WithHmacSHA256);
String springEncoded = springEncoder.encode(PASSWORD);
assertThat(springEncoder.matches(PASSWORD, springEncoded)).isTrue();
// Password4j PBKDF2
Password4jPasswordEncoder password4jEncoder = new Password4jPasswordEncoder(
CompressedPBKDF2Function.getInstance("SHA256", 100000, 32));
String password4jEncoded = password4jEncoder.encode(PASSWORD);
assertThat(password4jEncoder.matches(PASSWORD, password4jEncoded)).isTrue();
// Note: These encoders use different hash formats and are not cross-compatible
// This is expected behavior due to different implementation standards
}
// Cross-Algorithm Tests (should fail)
@Test
void bcryptEncodedPasswordShouldNotMatchArgon2Encoder() {
Password4jPasswordEncoder bcryptEncoder = new Password4jPasswordEncoder(BcryptFunction.getInstance(10));
Password4jPasswordEncoder argon2Encoder = new Password4jPasswordEncoder(AlgorithmFinder.getArgon2Instance());
String bcryptEncoded = bcryptEncoder.encode(PASSWORD);
boolean matchedByArgon2 = argon2Encoder.matches(PASSWORD, bcryptEncoded);
assertThat(matchedByArgon2).isFalse();
}
@Test
void argon2EncodedPasswordShouldNotMatchScryptEncoder() {
Password4jPasswordEncoder argon2Encoder = new Password4jPasswordEncoder(AlgorithmFinder.getArgon2Instance());
Password4jPasswordEncoder scryptEncoder = new Password4jPasswordEncoder(AlgorithmFinder.getScryptInstance());
String argon2Encoded = argon2Encoder.encode(PASSWORD);
boolean matchedByScrypt = scryptEncoder.matches(PASSWORD, argon2Encoded);
assertThat(matchedByScrypt).isFalse();
}
}

View File

@ -0,0 +1,248 @@
/*
* Copyright 2004-present the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.security.crypto.password4j;
import com.password4j.AlgorithmFinder;
import com.password4j.ScryptFunction;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
/**
* Tests for {@link ScryptPassword4jPasswordEncoder}.
*
* @author Mehrdad Bozorgmehr
*/
class ScryptPassword4jPasswordEncoderTests {
private static final String PASSWORD = "password";
private static final String LONG_PASSWORD = "a".repeat(1000);
private static final String SPECIAL_CHARS_PASSWORD = "p@ssw0rd!#$%^&*()_+-=[]{}|;':\",./<>?";
private static final String UNICODE_PASSWORD = "пароль密码パスワード🔐";
@Test
void defaultConstructorShouldCreateWorkingEncoder() {
ScryptPassword4jPasswordEncoder encoder = new ScryptPassword4jPasswordEncoder();
String encoded = encoder.encode(PASSWORD);
assertThat(encoded).isNotNull();
// Password4j scrypt format differs from classic $s0$; accept generic multi-part
// format
assertThat(encoded.split("\\$").length).isGreaterThanOrEqualTo(3);
assertThat(encoder.matches(PASSWORD, encoded)).isTrue();
}
@Test
void constructorWithNullScryptFunctionShouldThrowException() {
assertThatIllegalArgumentException().isThrownBy(() -> new ScryptPassword4jPasswordEncoder(null))
.withMessage("hashingFunction cannot be null");
}
@Test
void constructorWithCustomScryptFunctionShouldWork() {
ScryptFunction customFunction = ScryptFunction.getInstance(16384, 8, 1, 32);
ScryptPassword4jPasswordEncoder encoder = new ScryptPassword4jPasswordEncoder(customFunction);
String encoded = encoder.encode(PASSWORD);
assertThat(encoded).isNotNull();
assertThat(encoded.split("\\$").length).isGreaterThanOrEqualTo(3);
assertThat(encoder.matches(PASSWORD, encoded)).isTrue();
}
@ParameterizedTest
@CsvSource({ "1024, 8, 1, 16", "4096, 8, 1, 32", "16384, 8, 1, 32", "32768, 8, 1, 64" })
void encodingShouldWorkWithDifferentParameters(int N, int r, int p, int dkLen) {
ScryptFunction function = ScryptFunction.getInstance(N, r, p, dkLen);
ScryptPassword4jPasswordEncoder encoder = new ScryptPassword4jPasswordEncoder(function);
String encoded = encoder.encode(PASSWORD);
assertThat(encoded).isNotNull();
assertThat(encoded.split("\\$").length).isGreaterThanOrEqualTo(3);
assertThat(encoder.matches(PASSWORD, encoded)).isTrue();
}
@Test
void encodingShouldGenerateDifferentHashesForSamePassword() {
ScryptPassword4jPasswordEncoder encoder = new ScryptPassword4jPasswordEncoder();
String hash1 = encoder.encode(PASSWORD);
String hash2 = encoder.encode(PASSWORD);
assertThat(hash1).isNotEqualTo(hash2);
assertThat(encoder.matches(PASSWORD, hash1)).isTrue();
assertThat(encoder.matches(PASSWORD, hash2)).isTrue();
}
@Test
void shouldHandleLongPasswords() {
ScryptPassword4jPasswordEncoder encoder = new ScryptPassword4jPasswordEncoder();
String encoded = encoder.encode(LONG_PASSWORD);
assertThat(encoder.matches(LONG_PASSWORD, encoded)).isTrue();
assertThat(encoder.matches("wrong", encoded)).isFalse();
}
@Test
void shouldHandleSpecialCharacters() {
ScryptPassword4jPasswordEncoder encoder = new ScryptPassword4jPasswordEncoder();
String encoded = encoder.encode(SPECIAL_CHARS_PASSWORD);
assertThat(encoder.matches(SPECIAL_CHARS_PASSWORD, encoded)).isTrue();
assertThat(encoder.matches("wrong", encoded)).isFalse();
}
@Test
void shouldHandleUnicodeCharacters() {
ScryptPassword4jPasswordEncoder encoder = new ScryptPassword4jPasswordEncoder();
String encoded = encoder.encode(UNICODE_PASSWORD);
assertThat(encoder.matches(UNICODE_PASSWORD, encoded)).isTrue();
assertThat(encoder.matches("wrong", encoded)).isFalse();
}
@Test
void shouldRejectIncorrectPasswords() {
ScryptPassword4jPasswordEncoder encoder = new ScryptPassword4jPasswordEncoder();
String encoded = encoder.encode(PASSWORD);
assertThat(encoder.matches("wrongpassword", encoded)).isFalse();
assertThat(encoder.matches("PASSWORD", encoded)).isFalse(); // Case sensitive
assertThat(encoder.matches("password ", encoded)).isFalse(); // Trailing space
assertThat(encoder.matches(" password", encoded)).isFalse(); // Leading space
}
@Test
void matchesShouldReturnFalseForNullOrEmptyInputs() {
ScryptPassword4jPasswordEncoder encoder = new ScryptPassword4jPasswordEncoder();
String encoded = encoder.encode(PASSWORD);
assertThat(encoder.matches(null, encoded)).isFalse();
assertThat(encoder.matches("", encoded)).isFalse();
assertThat(encoder.matches(PASSWORD, null)).isFalse();
assertThat(encoder.matches(PASSWORD, "")).isFalse();
assertThat(encoder.matches(null, null)).isFalse();
assertThat(encoder.matches("", "")).isFalse();
}
@Test
void encodeNullShouldReturnNull() {
ScryptPassword4jPasswordEncoder encoder = new ScryptPassword4jPasswordEncoder();
assertThat(encoder.encode(null)).isNull();
}
@Test
void upgradeEncodingShouldReturnFalse() {
ScryptPassword4jPasswordEncoder encoder = new ScryptPassword4jPasswordEncoder();
String encoded = encoder.encode(PASSWORD);
assertThat(encoder.upgradeEncoding(encoded)).isFalse();
}
@Test
void shouldWorkWithAlgorithmFinderDefaults() {
ScryptPassword4jPasswordEncoder encoder = new ScryptPassword4jPasswordEncoder(
AlgorithmFinder.getScryptInstance());
String encoded = encoder.encode(PASSWORD);
assertThat(encoded).isNotNull();
assertThat(encoder.matches(PASSWORD, encoded)).isTrue();
}
@Test
void shouldRejectMalformedHashes() {
ScryptPassword4jPasswordEncoder encoder = new ScryptPassword4jPasswordEncoder();
assertMalformedRejected(encoder, PASSWORD, "invalid_hash");
assertMalformedRejected(encoder, PASSWORD, "$s0$invalid");
assertMalformedRejected(encoder, PASSWORD, "");
}
private void assertMalformedRejected(ScryptPassword4jPasswordEncoder encoder, String raw, String malformed) {
boolean rejected;
try {
rejected = !encoder.matches(raw, malformed);
}
catch (RuntimeException ex) {
rejected = true; // exception path acceptable
}
assertThat(rejected).as("Malformed hash should not validate: " + malformed).isTrue();
}
@Test
void shouldHandleEmptyStringPassword() {
ScryptPassword4jPasswordEncoder encoder = new ScryptPassword4jPasswordEncoder();
String encoded = encoder.encode("");
assertThat(encoded).isNotNull();
boolean emptyMatches;
try {
emptyMatches = encoder.matches("", encoded);
}
catch (RuntimeException ex) {
emptyMatches = false;
}
if (emptyMatches) {
assertThat(encoder.matches("", encoded)).isTrue();
}
assertThat(encoder.matches("notEmpty", encoded)).isFalse();
}
@Test
void shouldHandleCustomCostParameters() {
// Test with low cost parameters for speed
ScryptFunction lowCost = ScryptFunction.getInstance(1024, 1, 1, 16);
// Test with higher cost parameters
ScryptFunction highCost = ScryptFunction.getInstance(32768, 8, 2, 64);
ScryptPassword4jPasswordEncoder lowEncoder = new ScryptPassword4jPasswordEncoder(lowCost);
ScryptPassword4jPasswordEncoder highEncoder = new ScryptPassword4jPasswordEncoder(highCost);
String lowEncoded = lowEncoder.encode(PASSWORD);
String highEncoded = highEncoder.encode(PASSWORD);
assertThat(lowEncoder.matches(PASSWORD, lowEncoded)).isTrue();
assertThat(highEncoder.matches(PASSWORD, highEncoded)).isTrue();
// Each encoder should work with hashes generated by the same parameters
assertThat(lowEncoded).isNotEqualTo(highEncoded);
}
@Test
void shouldHandleEdgeCaseParameters() {
// Test with minimum practical parameters
ScryptFunction minParams = ScryptFunction.getInstance(2, 1, 1, 1);
ScryptPassword4jPasswordEncoder encoder = new ScryptPassword4jPasswordEncoder(minParams);
String encoded = encoder.encode(PASSWORD);
assertThat(encoded).isNotNull();
assertThat(encoder.matches(PASSWORD, encoded)).isTrue();
}
}