Create a specific implementation for BalloonHashing and PBKDF2 password encoders using Password4j library

Closes gh-17706

Signed-off-by: Mehrdad <mehrdad.bozorgmehr@gmail.com>
Signed-off-by: M.Bozorgmehr <mehrdad.bozorgmehr@gmail.com>
This commit is contained in:
Mehrdad 2025-09-12 00:00:38 +03:30 committed by M.Bozorgmehr
parent 8c2ad4e4d1
commit 2d74f9c334
5 changed files with 693 additions and 3 deletions

View File

@ -0,0 +1,159 @@
/*
* 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 java.security.SecureRandom;
import java.util.Base64;
import com.password4j.AlgorithmFinder;
import com.password4j.BalloonHashingFunction;
import com.password4j.Hash;
import com.password4j.Password;
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 with Balloon hashing algorithm.
*
* <p>
* Balloon hashing is a memory-hard password hashing algorithm designed to be resistant to
* both time-memory trade-off attacks and side-channel attacks. This implementation
* handles the salt management explicitly since Password4j's Balloon hashing
* implementation does not include the salt in the output hash.
* </p>
*
* <p>
* The encoded password format is: {salt}:{hash} where both salt and hash are Base64
* encoded.
* </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 Balloon hashing settings (recommended)
* PasswordEncoder encoder = new BalloonHashingPassword4jPasswordEncoder();
*
* // Using custom Balloon hashing function
* PasswordEncoder customEncoder = new BalloonHashingPassword4jPasswordEncoder(
* BalloonHashingFunction.getInstance(1024, 3, 4, "SHA-256"));
* }</pre>
*
* @author Mehrdad Bozorgmehr
* @since 7.0
* @see BalloonHashingFunction
* @see AlgorithmFinder#getBalloonHashingInstance()
*/
public class BalloonHashingPassword4jPasswordEncoder extends AbstractValidatingPasswordEncoder {
private static final String DELIMITER = ":";
private static final int DEFAULT_SALT_LENGTH = 32;
private final BalloonHashingFunction balloonHashingFunction;
private final SecureRandom secureRandom;
private final int saltLength;
/**
* Constructs a Balloon hashing password encoder using the default Balloon hashing
* configuration from Password4j's AlgorithmFinder.
*/
public BalloonHashingPassword4jPasswordEncoder() {
this(AlgorithmFinder.getBalloonHashingInstance());
}
/**
* Constructs a Balloon hashing password encoder with a custom Balloon hashing
* function.
* @param balloonHashingFunction the Balloon hashing function to use for encoding
* passwords, must not be null
* @throws IllegalArgumentException if balloonHashingFunction is null
*/
public BalloonHashingPassword4jPasswordEncoder(BalloonHashingFunction balloonHashingFunction) {
this(balloonHashingFunction, DEFAULT_SALT_LENGTH);
}
/**
* Constructs a Balloon hashing password encoder with a custom Balloon hashing
* function and salt length.
* @param balloonHashingFunction the Balloon hashing function to use for encoding
* passwords, must not be null
* @param saltLength the length of the salt in bytes, must be positive
* @throws IllegalArgumentException if balloonHashingFunction is null or saltLength is
* not positive
*/
public BalloonHashingPassword4jPasswordEncoder(BalloonHashingFunction balloonHashingFunction, int saltLength) {
Assert.notNull(balloonHashingFunction, "balloonHashingFunction cannot be null");
Assert.isTrue(saltLength > 0, "saltLength must be positive");
this.balloonHashingFunction = balloonHashingFunction;
this.saltLength = saltLength;
this.secureRandom = new SecureRandom();
}
@Override
protected String encodeNonNullPassword(String rawPassword) {
byte[] salt = new byte[this.saltLength];
this.secureRandom.nextBytes(salt);
Hash hash = Password.hash(rawPassword).addSalt(salt).with(this.balloonHashingFunction);
String encodedSalt = Base64.getEncoder().encodeToString(salt);
String encodedHash = hash.getResult();
return encodedSalt + DELIMITER + encodedHash;
}
@Override
protected boolean matchesNonNull(String rawPassword, String encodedPassword) {
if (!encodedPassword.contains(DELIMITER)) {
return false;
}
String[] parts = encodedPassword.split(DELIMITER, 2);
if (parts.length != 2) {
return false;
}
try {
byte[] salt = Base64.getDecoder().decode(parts[0]);
String expectedHash = parts[1];
Hash hash = Password.hash(rawPassword).addSalt(salt).with(this.balloonHashingFunction);
return expectedHash.equals(hash.getResult());
}
catch (IllegalArgumentException ex) {
// Invalid Base64 encoding
return false;
}
}
@Override
protected boolean upgradeEncodingNonNull(String encodedPassword) {
// For now, we'll return false to maintain existing behavior
// This could be enhanced in the future to check if the encoding parameters
// match the current configuration
return false;
}
}

View File

@ -0,0 +1,157 @@
/*
* 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 java.security.SecureRandom;
import java.util.Base64;
import com.password4j.AlgorithmFinder;
import com.password4j.Hash;
import com.password4j.PBKDF2Function;
import com.password4j.Password;
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 with PBKDF2 hashing algorithm.
*
* <p>
* PBKDF2 is a key derivation function designed to be computationally expensive to thwart
* dictionary and brute force attacks. This implementation handles the salt management
* explicitly since Password4j's PBKDF2 implementation does not include the salt in the
* output hash.
* </p>
*
* <p>
* The encoded password format is: {salt}:{hash} where both salt and hash are Base64
* encoded.
* </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 PBKDF2 settings (recommended)
* PasswordEncoder encoder = new Pbkdf2Password4jPasswordEncoder();
*
* // Using custom PBKDF2 function
* PasswordEncoder customEncoder = new Pbkdf2Password4jPasswordEncoder(
* PBKDF2Function.getInstance(Algorithm.HMAC_SHA256, 100000, 256));
* }</pre>
*
* @author Mehrdad Bozorgmehr
* @since 7.0
* @see PBKDF2Function
* @see AlgorithmFinder#getPBKDF2Instance()
*/
public class Pbkdf2Password4jPasswordEncoder extends AbstractValidatingPasswordEncoder {
private static final String DELIMITER = ":";
private static final int DEFAULT_SALT_LENGTH = 32;
private final PBKDF2Function pbkdf2Function;
private final SecureRandom secureRandom;
private final int saltLength;
/**
* Constructs a PBKDF2 password encoder using the default PBKDF2 configuration from
* Password4j's AlgorithmFinder.
*/
public Pbkdf2Password4jPasswordEncoder() {
this(AlgorithmFinder.getPBKDF2Instance());
}
/**
* Constructs a PBKDF2 password encoder with a custom PBKDF2 function.
* @param pbkdf2Function the PBKDF2 function to use for encoding passwords, must not
* be null
* @throws IllegalArgumentException if pbkdf2Function is null
*/
public Pbkdf2Password4jPasswordEncoder(PBKDF2Function pbkdf2Function) {
this(pbkdf2Function, DEFAULT_SALT_LENGTH);
}
/**
* Constructs a PBKDF2 password encoder with a custom PBKDF2 function and salt length.
* @param pbkdf2Function the PBKDF2 function to use for encoding passwords, must not
* be null
* @param saltLength the length of the salt in bytes, must be positive
* @throws IllegalArgumentException if pbkdf2Function is null or saltLength is not
* positive
*/
public Pbkdf2Password4jPasswordEncoder(PBKDF2Function pbkdf2Function, int saltLength) {
Assert.notNull(pbkdf2Function, "pbkdf2Function cannot be null");
Assert.isTrue(saltLength > 0, "saltLength must be positive");
this.pbkdf2Function = pbkdf2Function;
this.saltLength = saltLength;
this.secureRandom = new SecureRandom();
}
@Override
protected String encodeNonNullPassword(String rawPassword) {
byte[] salt = new byte[this.saltLength];
this.secureRandom.nextBytes(salt);
Hash hash = Password.hash(rawPassword).addSalt(salt).with(this.pbkdf2Function);
String encodedSalt = Base64.getEncoder().encodeToString(salt);
String encodedHash = hash.getResult();
return encodedSalt + DELIMITER + encodedHash;
}
@Override
protected boolean matchesNonNull(String rawPassword, String encodedPassword) {
if (!encodedPassword.contains(DELIMITER)) {
return false;
}
String[] parts = encodedPassword.split(DELIMITER, 2);
if (parts.length != 2) {
return false;
}
try {
byte[] salt = Base64.getDecoder().decode(parts[0]);
String expectedHash = parts[1];
Hash hash = Password.hash(rawPassword).addSalt(salt).with(this.pbkdf2Function);
return expectedHash.equals(hash.getResult());
}
catch (IllegalArgumentException ex) {
// Invalid Base64 encoding
return false;
}
}
@Override
protected boolean upgradeEncodingNonNull(String encodedPassword) {
// For now, we'll return false to maintain existing behavior
// This could be enhanced in the future to check if the encoding parameters
// match the current configuration
return false;
}
}

View File

@ -0,0 +1,170 @@
/*
* 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.BalloonHashingFunction;
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 BalloonHashingPassword4jPasswordEncoder}.
*
* @author Mehrdad Bozorgmehr
*/
class BalloonHashingPassword4jPasswordEncoderTests {
private static final String PASSWORD = "password";
private static final String DIFFERENT_PASSWORD = "differentpassword";
@Test
void constructorWithNullFunctionShouldThrowException() {
assertThatIllegalArgumentException().isThrownBy(() -> new BalloonHashingPassword4jPasswordEncoder(null))
.withMessage("balloonHashingFunction cannot be null");
}
@Test
void constructorWithInvalidSaltLengthShouldThrowException() {
BalloonHashingFunction function = AlgorithmFinder.getBalloonHashingInstance();
assertThatIllegalArgumentException().isThrownBy(() -> new BalloonHashingPassword4jPasswordEncoder(function, 0))
.withMessage("saltLength must be positive");
assertThatIllegalArgumentException().isThrownBy(() -> new BalloonHashingPassword4jPasswordEncoder(function, -1))
.withMessage("saltLength must be positive");
}
@Test
void defaultConstructorShouldWork() {
BalloonHashingPassword4jPasswordEncoder encoder = new BalloonHashingPassword4jPasswordEncoder();
String encoded = encoder.encode(PASSWORD);
assertThat(encoded).isNotNull().isNotEqualTo(PASSWORD);
assertThat(encoded).contains(":");
assertThat(encoder.matches(PASSWORD, encoded)).isTrue();
}
@Test
void customFunctionConstructorShouldWork() {
BalloonHashingFunction customFunction = BalloonHashingFunction.getInstance("SHA-256", 512, 2, 3);
BalloonHashingPassword4jPasswordEncoder encoder = new BalloonHashingPassword4jPasswordEncoder(customFunction);
String encoded = encoder.encode(PASSWORD);
assertThat(encoded).isNotNull().isNotEqualTo(PASSWORD);
assertThat(encoded).contains(":");
assertThat(encoder.matches(PASSWORD, encoded)).isTrue();
}
@Test
void customSaltLengthConstructorShouldWork() {
BalloonHashingFunction function = AlgorithmFinder.getBalloonHashingInstance();
BalloonHashingPassword4jPasswordEncoder encoder = new BalloonHashingPassword4jPasswordEncoder(function, 16);
String encoded = encoder.encode(PASSWORD);
assertThat(encoded).isNotNull().isNotEqualTo(PASSWORD);
assertThat(encoded).contains(":");
assertThat(encoder.matches(PASSWORD, encoded)).isTrue();
}
@Test
void encodeShouldIncludeSaltInOutput() {
BalloonHashingPassword4jPasswordEncoder encoder = new BalloonHashingPassword4jPasswordEncoder();
String encoded = encoder.encode(PASSWORD);
assertThat(encoded).contains(":");
String[] parts = encoded.split(":");
assertThat(parts).hasSize(2);
assertThat(parts[0]).isNotEmpty(); // salt part
assertThat(parts[1]).isNotEmpty(); // hash part
}
@Test
void matchesShouldReturnTrueForCorrectPassword() {
BalloonHashingPassword4jPasswordEncoder encoder = new BalloonHashingPassword4jPasswordEncoder();
String encoded = encoder.encode(PASSWORD);
boolean matches = encoder.matches(PASSWORD, encoded);
assertThat(matches).isTrue();
}
@Test
void matchesShouldReturnFalseForIncorrectPassword() {
BalloonHashingPassword4jPasswordEncoder encoder = new BalloonHashingPassword4jPasswordEncoder();
String encoded = encoder.encode(PASSWORD);
boolean matches = encoder.matches(DIFFERENT_PASSWORD, encoded);
assertThat(matches).isFalse();
}
@Test
void matchesShouldReturnFalseForMalformedEncodedPassword() {
BalloonHashingPassword4jPasswordEncoder encoder = new BalloonHashingPassword4jPasswordEncoder();
assertThat(encoder.matches(PASSWORD, "malformed")).isFalse();
assertThat(encoder.matches(PASSWORD, "no:delimiter:in:wrong:places")).isFalse();
assertThat(encoder.matches(PASSWORD, "invalid_base64!:hash")).isFalse();
}
@Test
void multipleEncodingsShouldProduceDifferentHashesButAllMatch() {
BalloonHashingPassword4jPasswordEncoder encoder = new BalloonHashingPassword4jPasswordEncoder();
String encoded1 = encoder.encode(PASSWORD);
String encoded2 = encoder.encode(PASSWORD);
assertThat(encoded1).isNotEqualTo(encoded2); // Different salts should produce
// different results
assertThat(encoder.matches(PASSWORD, encoded1)).isTrue();
assertThat(encoder.matches(PASSWORD, encoded2)).isTrue();
}
@Test
void upgradeEncodingShouldReturnFalse() {
BalloonHashingPassword4jPasswordEncoder encoder = new BalloonHashingPassword4jPasswordEncoder();
String encoded = encoder.encode(PASSWORD);
boolean shouldUpgrade = encoder.upgradeEncoding(encoded);
assertThat(shouldUpgrade).isFalse();
}
@Test
void encodeNullShouldReturnNull() {
BalloonHashingPassword4jPasswordEncoder encoder = new BalloonHashingPassword4jPasswordEncoder();
assertThat(encoder.encode(null)).isNull();
}
@Test
void matchesWithNullOrEmptyValuesShouldReturnFalse() {
BalloonHashingPassword4jPasswordEncoder encoder = new BalloonHashingPassword4jPasswordEncoder();
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();
}
}

View File

@ -24,6 +24,7 @@ import org.junit.jupiter.api.Test;
import org.springframework.security.crypto.argon2.Argon2PasswordEncoder; import org.springframework.security.crypto.argon2.Argon2PasswordEncoder;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.Pbkdf2PasswordEncoder;
import org.springframework.security.crypto.scrypt.SCryptPasswordEncoder; import org.springframework.security.crypto.scrypt.SCryptPasswordEncoder;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
@ -52,7 +53,7 @@ class PasswordCompatibilityTests {
} }
@Test @Test
void bcryptEncodedWithPassword4jShouldMatchWithSpringSecirity() { void bcryptEncodedWithPassword4jShouldMatchWithSpringSecurity() {
BcryptPassword4jPasswordEncoder password4jEncoder = new BcryptPassword4jPasswordEncoder( BcryptPassword4jPasswordEncoder password4jEncoder = new BcryptPassword4jPasswordEncoder(
BcryptFunction.getInstance(10)); BcryptFunction.getInstance(10));
BCryptPasswordEncoder springEncoder = new BCryptPasswordEncoder(10); BCryptPasswordEncoder springEncoder = new BCryptPasswordEncoder(10);
@ -77,7 +78,7 @@ class PasswordCompatibilityTests {
} }
@Test @Test
void argon2EncodedWithPassword4jShouldMatchWithSpringSecirity() { void argon2EncodedWithPassword4jShouldMatchWithSpringSecurity() {
Argon2Password4jPasswordEncoder password4jEncoder = new Argon2Password4jPasswordEncoder( Argon2Password4jPasswordEncoder password4jEncoder = new Argon2Password4jPasswordEncoder(
Argon2Function.getInstance(4096, 3, 1, 32, Argon2.ID)); Argon2Function.getInstance(4096, 3, 1, 32, Argon2.ID));
Argon2PasswordEncoder springEncoder = new Argon2PasswordEncoder(16, 32, 1, 4096, 3); Argon2PasswordEncoder springEncoder = new Argon2PasswordEncoder(16, 32, 1, 4096, 3);
@ -102,7 +103,7 @@ class PasswordCompatibilityTests {
} }
@Test @Test
void scryptEncodedWithPassword4jShouldMatchWithSpringSecirity() { void scryptEncodedWithPassword4jShouldMatchWithSpringSecurity() {
ScryptPassword4jPasswordEncoder password4jEncoder = new ScryptPassword4jPasswordEncoder( ScryptPassword4jPasswordEncoder password4jEncoder = new ScryptPassword4jPasswordEncoder(
ScryptFunction.getInstance(16384, 8, 1, 32)); ScryptFunction.getInstance(16384, 8, 1, 32));
SCryptPasswordEncoder springEncoder = new SCryptPasswordEncoder(16384, 8, 1, 32, 64); SCryptPasswordEncoder springEncoder = new SCryptPasswordEncoder(16384, 8, 1, 32, 64);
@ -113,4 +114,40 @@ class PasswordCompatibilityTests {
assertThat(matchedBySpring).isTrue(); assertThat(matchedBySpring).isTrue();
} }
// PBKDF2 Compatibility Tests
@Test
void pbkdf2EncodedWithSpringSecurityCannotMatchWithPassword4j() {
// Note: Direct compatibility between Spring Security's Pbkdf2PasswordEncoder
// and Password4j's PBKDF2 implementation is not possible because they use
// different output formats. Spring Security uses hex encoding with a specific
// format,
// while our Password4jPasswordEncoder uses salt:hash format with Base64 encoding.
Pbkdf2PasswordEncoder springEncoder = Pbkdf2PasswordEncoder.defaultsForSpringSecurity_v5_8();
Pbkdf2Password4jPasswordEncoder password4jEncoder = new Pbkdf2Password4jPasswordEncoder();
String encodedBySpring = springEncoder.encode(PASSWORD);
String encodedByPassword4j = password4jEncoder.encode(PASSWORD);
// These should NOT match due to different formats
// Spring Security will throw an exception when trying to decode Password4j
// format,
// which should be treated as a non-match
boolean password4jCanMatchSpring = password4jEncoder.matches(PASSWORD, encodedBySpring);
boolean springCanMatchPassword4j;
try {
springCanMatchPassword4j = springEncoder.matches(PASSWORD, encodedByPassword4j);
}
catch (IllegalArgumentException ex) {
// Expected exception due to format incompatibility - treat as non-match
springCanMatchPassword4j = false;
}
assertThat(password4jCanMatchSpring).isFalse();
assertThat(springCanMatchPassword4j).isFalse();
// But each should match its own encoding
assertThat(springEncoder.matches(PASSWORD, encodedBySpring)).isTrue();
assertThat(password4jEncoder.matches(PASSWORD, encodedByPassword4j)).isTrue();
}
} }

View File

@ -0,0 +1,167 @@
/*
* 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.PBKDF2Function;
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 Pbkdf2Password4jPasswordEncoder}.
*
* @author Mehrdad Bozorgmehr
*/
class Pbkdf2Password4jPasswordEncoderTests {
private static final String PASSWORD = "password";
private static final String DIFFERENT_PASSWORD = "differentpassword";
@Test
void constructorWithNullFunctionShouldThrowException() {
assertThatIllegalArgumentException().isThrownBy(() -> new Pbkdf2Password4jPasswordEncoder(null))
.withMessage("pbkdf2Function cannot be null");
}
@Test
void constructorWithInvalidSaltLengthShouldThrowException() {
PBKDF2Function function = AlgorithmFinder.getPBKDF2Instance();
assertThatIllegalArgumentException().isThrownBy(() -> new Pbkdf2Password4jPasswordEncoder(function, 0))
.withMessage("saltLength must be positive");
assertThatIllegalArgumentException().isThrownBy(() -> new Pbkdf2Password4jPasswordEncoder(function, -1))
.withMessage("saltLength must be positive");
}
@Test
void defaultConstructorShouldWork() {
Pbkdf2Password4jPasswordEncoder encoder = new Pbkdf2Password4jPasswordEncoder();
String encoded = encoder.encode(PASSWORD);
assertThat(encoded).isNotNull().isNotEqualTo(PASSWORD).contains(":");
assertThat(encoder.matches(PASSWORD, encoded)).isTrue();
}
@Test
void customFunctionConstructorShouldWork() {
PBKDF2Function customFunction = AlgorithmFinder.getPBKDF2Instance();
Pbkdf2Password4jPasswordEncoder encoder = new Pbkdf2Password4jPasswordEncoder(customFunction);
String encoded = encoder.encode(PASSWORD);
assertThat(encoded).isNotNull().isNotEqualTo(PASSWORD).contains(":");
assertThat(encoder.matches(PASSWORD, encoded)).isTrue();
}
@Test
void customSaltLengthConstructorShouldWork() {
PBKDF2Function function = AlgorithmFinder.getPBKDF2Instance();
Pbkdf2Password4jPasswordEncoder encoder = new Pbkdf2Password4jPasswordEncoder(function, 16);
String encoded = encoder.encode(PASSWORD);
assertThat(encoded).isNotNull().isNotEqualTo(PASSWORD).contains(":");
assertThat(encoder.matches(PASSWORD, encoded)).isTrue();
}
@Test
void encodeShouldIncludeSaltInOutput() {
Pbkdf2Password4jPasswordEncoder encoder = new Pbkdf2Password4jPasswordEncoder();
String encoded = encoder.encode(PASSWORD);
assertThat(encoded).contains(":");
String[] parts = encoded.split(":");
assertThat(parts).hasSize(2);
assertThat(parts[0]).isNotEmpty(); // salt part
assertThat(parts[1]).isNotEmpty(); // hash part
}
@Test
void matchesShouldReturnTrueForCorrectPassword() {
Pbkdf2Password4jPasswordEncoder encoder = new Pbkdf2Password4jPasswordEncoder();
String encoded = encoder.encode(PASSWORD);
boolean matches = encoder.matches(PASSWORD, encoded);
assertThat(matches).isTrue();
}
@Test
void matchesShouldReturnFalseForIncorrectPassword() {
Pbkdf2Password4jPasswordEncoder encoder = new Pbkdf2Password4jPasswordEncoder();
String encoded = encoder.encode(PASSWORD);
boolean matches = encoder.matches(DIFFERENT_PASSWORD, encoded);
assertThat(matches).isFalse();
}
@Test
void matchesShouldReturnFalseForMalformedEncodedPassword() {
Pbkdf2Password4jPasswordEncoder encoder = new Pbkdf2Password4jPasswordEncoder();
assertThat(encoder.matches(PASSWORD, "malformed")).isFalse();
assertThat(encoder.matches(PASSWORD, "no:delimiter:in:wrong:places")).isFalse();
assertThat(encoder.matches(PASSWORD, "invalid_base64!:hash")).isFalse();
}
@Test
void multipleEncodingsShouldProduceDifferentHashesButAllMatch() {
Pbkdf2Password4jPasswordEncoder encoder = new Pbkdf2Password4jPasswordEncoder();
String encoded1 = encoder.encode(PASSWORD);
String encoded2 = encoder.encode(PASSWORD);
assertThat(encoded1).isNotEqualTo(encoded2); // Different salts should produce
// different results
assertThat(encoder.matches(PASSWORD, encoded1)).isTrue();
assertThat(encoder.matches(PASSWORD, encoded2)).isTrue();
}
@Test
void upgradeEncodingShouldReturnFalse() {
Pbkdf2Password4jPasswordEncoder encoder = new Pbkdf2Password4jPasswordEncoder();
String encoded = encoder.encode(PASSWORD);
boolean shouldUpgrade = encoder.upgradeEncoding(encoded);
assertThat(shouldUpgrade).isFalse();
}
@Test
void encodeNullShouldReturnNull() {
Pbkdf2Password4jPasswordEncoder encoder = new Pbkdf2Password4jPasswordEncoder();
assertThat(encoder.encode(null)).isNull();
}
@Test
void matchesWithNullOrEmptyValuesShouldReturnFalse() {
Pbkdf2Password4jPasswordEncoder encoder = new Pbkdf2Password4jPasswordEncoder();
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();
}
}