mirror of
https://github.com/spring-projects/spring-security.git
synced 2025-10-23 10:48:51 +00:00
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:
parent
9f5d27e8d0
commit
8c2ad4e4d1
@ -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 @@
|
||||
/*
|
||||
* 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);
|
||||
}
|
||||
|
||||
}
|
@ -16,116 +16,56 @@
|
||||
|
||||
package org.springframework.security.crypto.password4j;
|
||||
|
||||
import com.password4j.AlgorithmFinder;
|
||||
import com.password4j.Hash;
|
||||
import com.password4j.HashingFunction;
|
||||
import com.password4j.Password;
|
||||
import org.apache.commons.logging.Log;
|
||||
import org.apache.commons.logging.LogFactory;
|
||||
|
||||
import org.springframework.security.crypto.password.AbstractValidatingPasswordEncoder;
|
||||
import org.springframework.util.Assert;
|
||||
|
||||
/**
|
||||
* Implementation of {@link org.springframework.security.crypto.password.PasswordEncoder}
|
||||
* that uses the Password4j library. This encoder supports multiple password hashing
|
||||
* algorithms including BCrypt, SCrypt, Argon2, and PBKDF2.
|
||||
* Abstract base class for Password4j-based password encoders. This class provides the
|
||||
* common functionality for password encoding and verification using the Password4j
|
||||
* library.
|
||||
*
|
||||
* <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);
|
||||
}
|
||||
Hash hash = Password.hash(rawPassword).with(this.hashingFunction);
|
||||
return hash.getResult();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected boolean matchesNonNull(String rawPassword, String encodedPassword) {
|
||||
try {
|
||||
// Use the specific hashing function for verification
|
||||
return Password.check(rawPassword, encodedPassword).with(this.hashingFunction);
|
||||
}
|
||||
catch (Exception ex) {
|
||||
this.logger.warn("Password verification failed for encoded password: " + encodedPassword, ex);
|
||||
return false;
|
||||
}
|
||||
return Password.check(rawPassword, encodedPassword).with(this.hashingFunction);
|
||||
}
|
||||
|
||||
@Override
|
||||
|
@ -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 @@
|
||||
/*
|
||||
* 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 @@
|
||||
/*
|
||||
* 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();
|
||||
}
|
||||
|
||||
}
|
@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -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…
x
Reference in New Issue
Block a user