Add password4j implementation of PasswordEncoder

This commit is contained in:
Rob Winch 2025-09-15 11:28:39 -05:00 committed by GitHub
commit 5ca18a3b9c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
30 changed files with 2515 additions and 1 deletions

View File

@ -8,6 +8,7 @@ dependencies {
management platform(project(":spring-security-dependencies"))
optional 'org.springframework:spring-core'
optional 'org.bouncycastle:bcpkix-jdk18on'
optional libs.com.password4j.password4j
testImplementation "org.assertj:assertj-core"
testImplementation "org.junit.jupiter:junit-jupiter-api"

View File

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

View File

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

View File

@ -0,0 +1,78 @@
/*
* 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.Hash;
import com.password4j.HashingFunction;
import com.password4j.Password;
import org.springframework.security.crypto.password.AbstractValidatingPasswordEncoder;
import org.springframework.util.Assert;
/**
* Abstract base class for Password4j-based password encoders. This class provides the
* common functionality for password encoding and verification using the Password4j
* library.
*
* <p>
* 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>
*
* @author Mehrdad Bozorgmehr
* @since 7.0
*/
abstract class Password4jPasswordEncoder extends AbstractValidatingPasswordEncoder {
private final HashingFunction hashingFunction;
/**
* 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
*/
Password4jPasswordEncoder(HashingFunction hashingFunction) {
Assert.notNull(hashingFunction, "hashingFunction cannot be null");
this.hashingFunction = hashingFunction;
}
@Override
protected String encodeNonNullPassword(String rawPassword) {
Hash hash = Password.hash(rawPassword).with(this.hashingFunction);
return hash.getResult();
}
@Override
protected boolean matchesNonNull(String rawPassword, String encodedPassword) {
return Password.check(rawPassword, encodedPassword).with(this.hashingFunction);
}
@Override
protected boolean upgradeEncodingNonNull(String encodedPassword) {
// Password4j handles upgrade detection internally for most algorithms
// For now, we'll return false to maintain existing behavior
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,74 @@
/*
* Copyright 2004-present the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.security.crypto.password4j;
import com.password4j.AlgorithmFinder;
import com.password4j.ScryptFunction;
/**
* Implementation of {@link org.springframework.security.crypto.password.PasswordEncoder}
* that uses the Password4j library with SCrypt hashing algorithm.
*
* <p>
* SCrypt is a memory-hard password hashing algorithm designed to be resistant to hardware
* brute-force attacks. It includes built-in salt generation and is particularly effective
* against ASIC and GPU-based attacks. This implementation leverages Password4j's SCrypt
* support which properly includes the salt in the output hash.
* </p>
*
* <p>
* This implementation is thread-safe and can be shared across multiple threads.
* </p>
*
* <p>
* <strong>Usage Examples:</strong>
* </p>
* <pre>{@code
* // Using default SCrypt settings (recommended)
* PasswordEncoder encoder = new ScryptPassword4jPasswordEncoder();
*
* // Using custom SCrypt configuration
* PasswordEncoder customEncoder = new ScryptPassword4jPasswordEncoder(
* ScryptFunction.getInstance(32768, 8, 1, 32));
* }</pre>
*
* @author Mehrdad Bozorgmehr
* @since 7.0
* @see ScryptFunction
* @see AlgorithmFinder#getScryptInstance()
*/
public class ScryptPassword4jPasswordEncoder extends Password4jPasswordEncoder {
/**
* Constructs an SCrypt password encoder using the default SCrypt configuration from
* Password4j's AlgorithmFinder.
*/
public ScryptPassword4jPasswordEncoder() {
super(AlgorithmFinder.getScryptInstance());
}
/**
* Constructs an SCrypt password encoder with a custom SCrypt function.
* @param scryptFunction the SCrypt function to use for encoding passwords, must not
* be null
* @throws IllegalArgumentException if scryptFunction is null
*/
public ScryptPassword4jPasswordEncoder(ScryptFunction scryptFunction) {
super(scryptFunction);
}
}

View File

@ -0,0 +1,20 @@
/*
* 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.
*/
@NullMarked
package org.springframework.security.crypto.password4j;
import org.jspecify.annotations.NullMarked;

View File

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

View File

@ -0,0 +1,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

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

View File

@ -0,0 +1,105 @@
/*
* 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.BcryptFunction;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Base functionality tests for {@link Password4jPasswordEncoder} implementations. These
* tests verify the common behavior across all concrete password encoder subclasses.
*
* @author Mehrdad Bozorgmehr
*/
class Password4jPasswordEncoderTests {
private static final String PASSWORD = "password";
private static final String WRONG_PASSWORD = "wrongpassword";
// Test abstract class behavior through concrete implementation
@Test
void encodeShouldReturnNonNullHashedPassword() {
BcryptPassword4jPasswordEncoder encoder = new BcryptPassword4jPasswordEncoder(BcryptFunction.getInstance(4));
String result = encoder.encode(PASSWORD);
assertThat(result).isNotNull().isNotEqualTo(PASSWORD);
}
@Test
void matchesShouldReturnTrueForValidPassword() {
BcryptPassword4jPasswordEncoder encoder = new BcryptPassword4jPasswordEncoder(BcryptFunction.getInstance(4));
String encoded = encoder.encode(PASSWORD);
boolean result = encoder.matches(PASSWORD, encoded);
assertThat(result).isTrue();
}
@Test
void matchesShouldReturnFalseForInvalidPassword() {
BcryptPassword4jPasswordEncoder encoder = new BcryptPassword4jPasswordEncoder(BcryptFunction.getInstance(4));
String encoded = encoder.encode(PASSWORD);
boolean result = encoder.matches(WRONG_PASSWORD, encoded);
assertThat(result).isFalse();
}
@Test
void encodeNullPasswordShouldReturnNull() {
BcryptPassword4jPasswordEncoder encoder = new BcryptPassword4jPasswordEncoder(BcryptFunction.getInstance(4));
assertThat(encoder.encode(null)).isNull();
}
@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() {
BcryptPassword4jPasswordEncoder encoder = new BcryptPassword4jPasswordEncoder(BcryptFunction.getInstance(4));
String encoded = encoder.encode(PASSWORD);
boolean result = encoder.upgradeEncoding(encoded);
assertThat(result).isFalse();
}
@Test
void matchesShouldReturnFalseWhenRawOrEncodedNullOrEmpty() {
BcryptPassword4jPasswordEncoder encoder = new BcryptPassword4jPasswordEncoder(BcryptFunction.getInstance(4));
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

@ -0,0 +1,153 @@
/*
* 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.Argon2Function;
import com.password4j.BcryptFunction;
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
* Password4j-based password encoders.
*
* @author Mehrdad Bozorgmehr
*/
class PasswordCompatibilityTests {
private static final String PASSWORD = "password";
// BCrypt Compatibility Tests
@Test
void bcryptEncodedWithSpringSecurityShouldMatchWithPassword4j() {
BCryptPasswordEncoder springEncoder = new BCryptPasswordEncoder(10);
BcryptPassword4jPasswordEncoder password4jEncoder = new BcryptPassword4jPasswordEncoder(
BcryptFunction.getInstance(10));
String encodedBySpring = springEncoder.encode(PASSWORD);
boolean matchedByPassword4j = password4jEncoder.matches(PASSWORD, encodedBySpring);
assertThat(matchedByPassword4j).isTrue();
}
@Test
void bcryptEncodedWithPassword4jShouldMatchWithSpringSecurity() {
BcryptPassword4jPasswordEncoder password4jEncoder = new BcryptPassword4jPasswordEncoder(
BcryptFunction.getInstance(10));
BCryptPasswordEncoder springEncoder = new BCryptPasswordEncoder(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 argon2EncodedWithPassword4jShouldMatchWithSpringSecurity() {
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);
assertThat(matchedBySpring).isTrue();
}
// SCrypt Compatibility Tests
@Test
void scryptEncodedWithSpringSecurityShouldMatchWithPassword4j() {
SCryptPasswordEncoder springEncoder = new SCryptPasswordEncoder(16384, 8, 1, 32, 64);
ScryptPassword4jPasswordEncoder password4jEncoder = new ScryptPassword4jPasswordEncoder(
ScryptFunction.getInstance(16384, 8, 1, 32));
String encodedBySpring = springEncoder.encode(PASSWORD);
boolean matchedByPassword4j = password4jEncoder.matches(PASSWORD, encodedBySpring);
assertThat(matchedByPassword4j).isTrue();
}
@Test
void scryptEncodedWithPassword4jShouldMatchWithSpringSecurity() {
ScryptPassword4jPasswordEncoder password4jEncoder = new ScryptPassword4jPasswordEncoder(
ScryptFunction.getInstance(16384, 8, 1, 32));
SCryptPasswordEncoder springEncoder = new SCryptPasswordEncoder(16384, 8, 1, 32, 64);
String encodedByPassword4j = password4jEncoder.encode(PASSWORD);
boolean matchedBySpring = springEncoder.matches(PASSWORD, encodedByPassword4j);
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();
}
}

View File

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

View File

@ -78,6 +78,6 @@ dependencies {
api libs.org.apache.maven.resolver.maven.resolver.transport.http
api libs.org.apache.maven.maven.resolver.provider
api libs.org.instancio.instancio.junit
api libs.com.password4j.password4j
}
}

View File

@ -463,6 +463,115 @@ There are a significant number of other `PasswordEncoder` implementations that e
They are all deprecated to indicate that they are no longer considered secure.
However, there are no plans to remove them, since it is difficult to migrate existing legacy systems.
[[password4j]]
== Password4j-based Password Encoders
Spring Security 7.0 introduces alternative password encoder implementations based on the https://github.com/Password4j/password4j[Password4j] library.
These encoders provide additional options for popular hashing algorithms and can be used as alternatives to the existing Spring Security implementations.
The Password4j library is a Java cryptographic library that focuses on password hashing with support for multiple algorithms.
These encoders are particularly useful when you need specific algorithm configurations or want to leverage Password4j's optimizations.
All Password4j-based encoders are thread-safe and can be shared across multiple threads.
[[password4j-argon2]]
=== Argon2Password4jPasswordEncoder
The `Argon2Password4jPasswordEncoder` implementation uses the https://en.wikipedia.org/wiki/Argon2[Argon2] algorithm via the Password4j library to hash passwords.
This provides an alternative to Spring Security's built-in `Argon2PasswordEncoder` with different configuration options and potential performance characteristics.
Argon2 is the winner of the https://en.wikipedia.org/wiki/Password_Hashing_Competition[Password Hashing Competition] and is recommended for new applications.
This implementation leverages Password4j's Argon2 support which properly includes the salt in the output hash.
Create an encoder with default settings:
.Argon2Password4jPasswordEncoder
include-code::./Argon2UsageTests[tag=default-params,indent=0]
Create an encoder with custom Argon2 parameters:
.Argon2Password4jPasswordEncoder Custom
include-code::./Argon2UsageTests[tag=custom-params,indent=0]
[[password4j-bcrypt]]
=== BcryptPassword4jPasswordEncoder
The `BcryptPassword4jPasswordEncoder` implementation uses the https://en.wikipedia.org/wiki/Bcrypt[BCrypt] algorithm via the Password4j library to hash passwords.
This provides an alternative to Spring Security's built-in `BCryptPasswordEncoder` with Password4j's implementation characteristics.
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.
Create an encoder with default settings:
.BcryptPassword4jPasswordEncoder
include-code::./BcryptUsageTests[tag=default-params,indent=0]
Create an encoder with custom bcrypt parameters:
.BcryptPassword4jPasswordEncoder Custom
include-code::./BcryptUsageTests[tag=custom-params,indent=0]
[[password4j-scrypt]]
=== ScryptPassword4jPasswordEncoder
The `ScryptPassword4jPasswordEncoder` implementation uses the https://en.wikipedia.org/wiki/Scrypt[SCrypt] algorithm via the Password4j library to hash passwords.
This provides an alternative to Spring Security's built-in `SCryptPasswordEncoder` with Password4j's implementation characteristics.
SCrypt is a memory-hard password hashing algorithm designed to be resistant to hardware brute-force attacks.
This implementation leverages Password4j's SCrypt support which properly includes the salt in the output hash.
Create an encoder with default settings:
.ScryptPassword4jPasswordEncoder
include-code::./ScryptUsageTests[tag=default-params,indent=0]
Create an encoder with custom scrypt parameters:
.ScryptPassword4jPasswordEncoder Custom
include-code::./ScryptUsageTests[tag=custom-params,indent=0]
[[password4j-pbkdf2]]
=== Pbkdf2Password4jPasswordEncoder
The `Pbkdf2Password4jPasswordEncoder` implementation uses the https://en.wikipedia.org/wiki/PBKDF2[PBKDF2] algorithm via the Password4j library to hash passwords.
This provides an alternative to Spring Security's built-in `Pbkdf2PasswordEncoder` with explicit salt management.
PBKDF2 is a key derivation function designed to be computationally expensive to thwart dictionary and brute force attacks.
This implementation handles salt management explicitly since Password4j's PBKDF2 implementation does not include the salt in the output hash.
The encoded password format is: `+{salt}:{hash}+` where both salt and hash are Base64 encoded.
Create an encoder with default settings:
.Pbkdf2Password4jPasswordEncoder
include-code::./Pbkdf2UsageTests[tag=default-params,indent=0]
Create an encoder with custom PBKDF2 parameters:
.Pbkdf2Password4jPasswordEncoder Custom
include-code::./Pbkdf2UsageTests[tag=custom-params,indent=0]
[[password4j-ballooning]]
=== BalloonHashingPassword4jPasswordEncoder
The `BalloonHashingPassword4jPasswordEncoder` implementation uses the Balloon hashing algorithm via the Password4j library to hash passwords.
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 salt management explicitly since Password4j's Balloon hashing implementation does not include the salt in the output hash.
The encoded password format is: `+{salt}:{hash}+` where both salt and hash are Base64 encoded.
Create an encoder with default settings:
.BalloonHashingPassword4jPasswordEncoder
include-code::./BallooningHashingUsageTests[tag=default-params,indent=0]
Create an encoder with custom parameters:
.BalloonHashingPassword4jPasswordEncoder Custom
include-code::./BallooningHashingUsageTests[tag=custom-params,indent=0]
[[authentication-password-storage-configuration]]
== Password Storage Configuration

View File

@ -35,6 +35,15 @@ Java::
http.csrf((csrf) -> csrf.spa());
----
== Crypto
* Added Password4j-based password encoders providing alternative implementations for popular hashing algorithms:
** `Argon2Password4jPasswordEncoder` - xref:features/authentication/password-storage.adoc#password4j-argon2[Argon2]
** `BcryptPassword4jPasswordEncoder` - xref:features/authentication/password-storage.adoc#password4j-bcrypt[BCrypt]
** `ScryptPassword4jPasswordEncoder` - xref:features/authentication/password-storage.adoc#password4j-scrypt[SCrypt]
** `Pbkdf2Password4jPasswordEncoder` - xref:features/authentication/password-storage.adoc#password4j-pbkdf2[PBKDF2]
** `BalloonHashingPassword4jPasswordEncoder` - xref:features/authentication/password-storage.adoc#password4j-balloon[Balloon Hashing]
== Data
* Added support to Authorized objects for Spring Data types

View File

@ -39,6 +39,7 @@ dependencies {
testImplementation project(':spring-security-test')
testImplementation project(':spring-security-oauth2-client')
testImplementation 'com.squareup.okhttp3:mockwebserver'
testImplementation libs.com.password4j.password4j
testImplementation 'com.unboundid:unboundid-ldapsdk'
testImplementation libs.webauthn4j.core
testImplementation 'org.jetbrains.kotlin:kotlin-reflect'

View File

@ -0,0 +1,53 @@
/*
* 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.docs.features.authentication.password4jargon2;
import com.password4j.Argon2Function;
import com.password4j.types.Argon2;
import org.junit.jupiter.api.Test;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.crypto.password4j.Argon2Password4jPasswordEncoder;
import static org.assertj.core.api.Assertions.assertThat;
/**
* @author Rob Winch
*/
public class Argon2UsageTests {
@Test
void defaultParams() {
// tag::default-params[]
PasswordEncoder encoder = new Argon2Password4jPasswordEncoder();
String result = encoder.encode("myPassword");
assertThat(encoder.matches("myPassword", result)).isTrue();
// end::default-params[]
}
@Test
void customParameters() {
// tag::custom-params[]
Argon2Function argon2Fn = Argon2Function.getInstance(65536, 3, 4, 32,
Argon2.ID);
PasswordEncoder encoder = new Argon2Password4jPasswordEncoder(argon2Fn);
String result = encoder.encode("myPassword");
assertThat(encoder.matches("myPassword", result)).isTrue();
// end::custom-params[]
}
}

View File

@ -0,0 +1,52 @@
/*
* 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.docs.features.authentication.password4jballooning;
import com.password4j.BalloonHashingFunction;
import org.junit.jupiter.api.Test;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.crypto.password4j.BalloonHashingPassword4jPasswordEncoder;
import static org.assertj.core.api.Assertions.assertThat;
/**
* @author Rob Winch
*/
public class BallooningHashingUsageTests {
@Test
void defaultParams() {
// tag::default-params[]
PasswordEncoder encoder = new BalloonHashingPassword4jPasswordEncoder();
String result = encoder.encode("myPassword");
assertThat(encoder.matches("myPassword", result)).isTrue();
// end::default-params[]
}
@Test
void customParameters() {
// tag::custom-params[]
BalloonHashingFunction ballooningHashingFn =
BalloonHashingFunction.getInstance("SHA-256", 1024, 3, 4, 3);
PasswordEncoder encoder = new BalloonHashingPassword4jPasswordEncoder(ballooningHashingFn);
String result = encoder.encode("myPassword");
assertThat(encoder.matches("myPassword", result)).isTrue();
// end::custom-params[]
}
}

View File

@ -0,0 +1,52 @@
/*
* 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.docs.features.authentication.password4jbcrypt;
import com.password4j.BcryptFunction;
import org.junit.jupiter.api.Test;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.crypto.password4j.BcryptPassword4jPasswordEncoder;
import static org.assertj.core.api.Assertions.assertThat;
/**
* @author Rob Winch
*/
public class BcryptUsageTests {
@Test
void defaultParams() {
// tag::default-params[]
PasswordEncoder encoder = new BCryptPasswordEncoder();
String result = encoder.encode("myPassword");
assertThat(encoder.matches("myPassword", result)).isTrue();
// end::default-params[]
}
@Test
void customParameters() {
// tag::custom-params[]
BcryptFunction bcryptFn = BcryptFunction.getInstance(12);
PasswordEncoder encoder = new BcryptPassword4jPasswordEncoder(bcryptFn);
String result = encoder.encode("myPassword");
assertThat(encoder.matches("myPassword", result)).isTrue();
// end::custom-params[]
}
}

View File

@ -0,0 +1,52 @@
/*
* 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.docs.features.authentication.password4jpbkdf2;
import com.password4j.PBKDF2Function;
import com.password4j.types.Hmac;
import org.junit.jupiter.api.Test;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.crypto.password4j.Pbkdf2Password4jPasswordEncoder;
import static org.assertj.core.api.Assertions.assertThat;
/**
* @author Rob Winch
*/
public class Pbkdf2UsageTests {
@Test
void defaultParams() {
// tag::default-params[]
PasswordEncoder encoder = new Pbkdf2Password4jPasswordEncoder();
String result = encoder.encode("myPassword");
assertThat(encoder.matches("myPassword", result)).isTrue();
// end::default-params[]
}
@Test
void customParameters() {
// tag::custom-params[]
PBKDF2Function pbkdf2Fn = PBKDF2Function.getInstance(Hmac.SHA256, 100000, 256);
PasswordEncoder encoder = new Pbkdf2Password4jPasswordEncoder(pbkdf2Fn);
String result = encoder.encode("myPassword");
assertThat(encoder.matches("myPassword", result)).isTrue();
// end::custom-params[]
}
}

View File

@ -0,0 +1,51 @@
/*
* 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.docs.features.authentication.password4jscrypt;
import com.password4j.ScryptFunction;
import org.junit.jupiter.api.Test;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.crypto.password4j.ScryptPassword4jPasswordEncoder;
import static org.assertj.core.api.Assertions.assertThat;
/**
* @author Rob Winch
*/
public class ScryptUsageTests {
@Test
void defaultParams() {
// tag::default-params[]
PasswordEncoder encoder = new ScryptPassword4jPasswordEncoder();
String result = encoder.encode("myPassword");
assertThat(encoder.matches("myPassword", result)).isTrue();
// end::default-params[]
}
@Test
void customParameters() {
// tag::custom-params[]
ScryptFunction scryptFn = ScryptFunction.getInstance(32768, 8, 1, 32);
PasswordEncoder encoder = new ScryptPassword4jPasswordEncoder(scryptFn);
String result = encoder.encode("myPassword");
assertThat(encoder.matches("myPassword", result)).isTrue();
// end::custom-params[]
}
}

View File

@ -0,0 +1,51 @@
/*
* 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.kt.docs.features.authentication.password4jargon2
import com.password4j.Argon2Function
import com.password4j.types.Argon2
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.Test
import org.springframework.security.crypto.password.PasswordEncoder
import org.springframework.security.crypto.password4j.Argon2Password4jPasswordEncoder
/**
* @author Rob Winch
*/
class Argon2UsageTests {
@Test
fun defaultParams() {
// tag::default-params[]
val encoder: PasswordEncoder = Argon2Password4jPasswordEncoder()
val result = encoder.encode("myPassword")
assertThat(encoder.matches("myPassword", result)).isTrue()
// end::default-params[]
}
@Test
fun customParameters() {
// tag::custom-params[]
val argon2Fn = Argon2Function.getInstance(
65536, 3, 4, 32,
Argon2.ID
)
val encoder: PasswordEncoder = Argon2Password4jPasswordEncoder(argon2Fn)
val result = encoder.encode("myPassword")
assertThat(encoder.matches("myPassword", result)).isTrue()
// end::custom-params[]
}
}

View File

@ -0,0 +1,47 @@
/*
* 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.kt.docs.features.authentication.password4jballooning
import com.password4j.BalloonHashingFunction
import org.assertj.core.api.Assertions
import org.junit.jupiter.api.Test
import org.springframework.security.crypto.password.PasswordEncoder
import org.springframework.security.crypto.password4j.BalloonHashingPassword4jPasswordEncoder
/**
* @author Rob Winch
*/
class BallooningHashingUsageTests {
@Test
fun defaultParams() {
// tag::default-params[]
val encoder: PasswordEncoder = BalloonHashingPassword4jPasswordEncoder()
val result = encoder.encode("myPassword")
Assertions.assertThat(encoder.matches("myPassword", result)).isTrue()
// end::default-params[]
}
@Test
fun customParameters() {
// tag::custom-params[]
val ballooningHashingFn =
BalloonHashingFunction.getInstance("SHA-256", 1024, 3, 4, 3)
val encoder: PasswordEncoder = BalloonHashingPassword4jPasswordEncoder(ballooningHashingFn)
val result = encoder.encode("myPassword")
Assertions.assertThat(encoder.matches("myPassword", result)).isTrue()
// end::custom-params[]
}
}

View File

@ -0,0 +1,32 @@
package org.springframework.security.kt.docs.features.authentication.password4jbcrypt
import com.password4j.BcryptFunction
import org.assertj.core.api.Assertions
import org.junit.jupiter.api.Test
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder
import org.springframework.security.crypto.password.PasswordEncoder
import org.springframework.security.crypto.password4j.BcryptPassword4jPasswordEncoder
/**
* @author Rob Winch
*/
class BcryptUsageTests {
@Test
fun defaultParams() {
// tag::default-params[]
val encoder: PasswordEncoder = BCryptPasswordEncoder()
val result = encoder.encode("myPassword")
Assertions.assertThat(encoder.matches("myPassword", result)).isTrue()
// end::default-params[]
}
@Test
fun customParameters() {
// tag::custom-params[]
val bcryptFunction = BcryptFunction.getInstance(12)
val encoder: PasswordEncoder = BcryptPassword4jPasswordEncoder(bcryptFunction)
val result = encoder.encode("myPassword")
Assertions.assertThat(encoder.matches("myPassword", result)).isTrue()
// end::custom-params[]
}
}

View File

@ -0,0 +1,32 @@
package org.springframework.security.kt.docs.features.authentication.password4jpbkdf2
import com.password4j.PBKDF2Function
import com.password4j.types.Hmac
import org.assertj.core.api.Assertions
import org.junit.jupiter.api.Test
import org.springframework.security.crypto.password.PasswordEncoder
import org.springframework.security.crypto.password4j.Pbkdf2Password4jPasswordEncoder
/**
* @author Rob Winch
*/
class Pbkdf2UsageTests {
@Test
fun defaultParams() {
// tag::default-params[]
val encoder: PasswordEncoder = Pbkdf2Password4jPasswordEncoder()
val result = encoder.encode("myPassword")
Assertions.assertThat(encoder.matches("myPassword", result)).isTrue()
// end::default-params[]
}
@Test
fun customParameters() {
// tag::custom-params[]
val pbkdf2Fn = PBKDF2Function.getInstance(Hmac.SHA256, 100000, 256)
val encoder: PasswordEncoder = Pbkdf2Password4jPasswordEncoder(pbkdf2Fn)
val result = encoder.encode("myPassword")
Assertions.assertThat(encoder.matches("myPassword", result)).isTrue()
// end::custom-params[]
}
}

View File

@ -0,0 +1,31 @@
package org.springframework.security.kt.docs.features.authentication.password4jscrypt
import com.password4j.ScryptFunction
import org.assertj.core.api.Assertions
import org.junit.jupiter.api.Test
import org.springframework.security.crypto.password.PasswordEncoder
import org.springframework.security.crypto.password4j.ScryptPassword4jPasswordEncoder
/**
* @author Rob Winch
*/
class ScryptUsageTests {
@Test
fun defaultParams() {
// tag::default-params[]
val encoder: PasswordEncoder = ScryptPassword4jPasswordEncoder()
val result = encoder.encode("myPassword")
Assertions.assertThat(encoder.matches("myPassword", result)).isTrue()
// end::default-params[]
}
@Test
fun customParameters() {
// tag::custom-params[]
val scryptFn = ScryptFunction.getInstance(32768, 8, 1, 32)
val encoder: PasswordEncoder = ScryptPassword4jPasswordEncoder(scryptFn)
val result = encoder.encode("myPassword")
Assertions.assertThat(encoder.matches("myPassword", result)).isTrue()
// end::custom-params[]
}
}

View File

@ -13,6 +13,7 @@ org-jetbrains-kotlinx = "1.10.2"
org-mockito = "5.17.0"
org-opensaml5 = "5.1.6"
org-springframework = "7.0.0-M9"
com-password4j = "1.8.2"
[libraries]
ch-qos-logback-logback-classic = "ch.qos.logback:logback-classic:1.5.18"
@ -101,6 +102,7 @@ org-instancio-instancio-junit = "org.instancio:instancio-junit:3.7.1"
spring-nullability = 'io.spring.nullability:io.spring.nullability.gradle.plugin:0.0.4'
webauthn4j-core = 'com.webauthn4j:webauthn4j-core:0.29.6.RELEASE'
com-password4j-password4j = { module = "com.password4j:password4j", version.ref = "com-password4j" }
[plugins]