Browse Source
Closes gh-17706 Signed-off-by: Mehrdad <mehrdad.bozorgmehr@gmail.com> Signed-off-by: M.Bozorgmehr <mehrdad.bozorgmehr@gmail.com>pull/17825/head
9 changed files with 994 additions and 204 deletions
@ -0,0 +1,74 @@
@@ -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); |
||||
} |
||||
|
||||
} |
||||
@ -0,0 +1,72 @@
@@ -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); |
||||
} |
||||
|
||||
} |
||||
@ -0,0 +1,74 @@
@@ -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); |
||||
} |
||||
|
||||
} |
||||
@ -0,0 +1,245 @@
@@ -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); |
||||
} |
||||
|
||||
} |
||||
@ -0,0 +1,217 @@
@@ -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(); |
||||
} |
||||
|
||||
} |
||||
@ -0,0 +1,248 @@
@@ -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(); |
||||
} |
||||
|
||||
} |
||||
Loading…
Reference in new issue