mirror of
https://github.com/spring-projects/spring-security.git
synced 2025-10-22 18:28:51 +00:00
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:
parent
8c2ad4e4d1
commit
2d74f9c334
@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -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();
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -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();
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@ -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();
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
Loading…
x
Reference in New Issue
Block a user