Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| dcc326be3c | |||
| 3b276e6c0d | |||
| f2115fbef8 | |||
| 298335a57e | |||
| c6566ee772 | |||
| 7442214225 | |||
| 0123ae0981 | |||
| 11a648d106 | |||
| 536e09756c | |||
| ac15cdbea3 | |||
| ad812aefc4 | |||
| d8769fb183 | |||
| ae997a4f46 | |||
| bbc6273abf | |||
| 475af0d3db | |||
| f8a61e66d1 | |||
| 8830e39073 | |||
| 304cc21523 | |||
| d111029078 |
+2
-1
@@ -17,6 +17,7 @@
|
||||
package org.springframework.security.config.annotation.authentication.configurers.ldap;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.InetAddress;
|
||||
import java.net.ServerSocket;
|
||||
|
||||
import org.springframework.ldap.core.support.BaseLdapPathContextSource;
|
||||
@@ -590,7 +591,7 @@ public class LdapAuthenticationProviderConfigurer<B extends ProviderManagerBuild
|
||||
}
|
||||
|
||||
private int getDefaultPort() {
|
||||
try (ServerSocket serverSocket = new ServerSocket(DEFAULT_PORT)) {
|
||||
try (ServerSocket serverSocket = new ServerSocket(DEFAULT_PORT, 50, InetAddress.getLoopbackAddress())) {
|
||||
return serverSocket.getLocalPort();
|
||||
}
|
||||
catch (IOException ex) {
|
||||
|
||||
+3
-1
@@ -79,8 +79,10 @@ final class DPoPAuthenticationConfigurer<B extends HttpSecurityBuilder<B>>
|
||||
|
||||
@Override
|
||||
public void configure(B http) {
|
||||
DPoPAuthenticationProvider authenticationProvider = new DPoPAuthenticationProvider(
|
||||
getTokenAuthenticationManager(http));
|
||||
http.authenticationProvider(postProcess(authenticationProvider));
|
||||
AuthenticationManager authenticationManager = http.getSharedObject(AuthenticationManager.class);
|
||||
http.authenticationProvider(new DPoPAuthenticationProvider(getTokenAuthenticationManager(http)));
|
||||
AuthenticationFilter authenticationFilter = new AuthenticationFilter(authenticationManager,
|
||||
getAuthenticationConverter());
|
||||
authenticationFilter.setRequestMatcher(getRequestMatcher());
|
||||
|
||||
+3
@@ -33,6 +33,7 @@ import org.springframework.security.authentication.AuthenticationManager;
|
||||
import org.springframework.security.authentication.AuthenticationManagerResolver;
|
||||
import org.springframework.security.authentication.AuthenticationProvider;
|
||||
import org.springframework.security.config.Customizer;
|
||||
import org.springframework.security.config.ObjectPostProcessor;
|
||||
import org.springframework.security.config.annotation.web.HttpSecurityBuilder;
|
||||
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
|
||||
import org.springframework.security.config.annotation.web.configurers.CsrfConfigurer;
|
||||
@@ -298,6 +299,8 @@ public final class OAuth2ResourceServerConfigurer<H extends HttpSecurityBuilder<
|
||||
|
||||
if (dPoPAuthenticationAvailable) {
|
||||
DPoPAuthenticationConfigurer<H> dPoPAuthenticationConfigurer = new DPoPAuthenticationConfigurer<>();
|
||||
dPoPAuthenticationConfigurer.withObjectPostProcessor(
|
||||
(ObjectPostProcessor<Object>) OAuth2ResourceServerConfigurer.this::postProcess);
|
||||
dPoPAuthenticationConfigurer.configure(http);
|
||||
}
|
||||
|
||||
|
||||
+2
-1
@@ -17,6 +17,7 @@
|
||||
package org.springframework.security.config.ldap;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.InetAddress;
|
||||
import java.net.ServerSocket;
|
||||
|
||||
import org.w3c.dom.Element;
|
||||
@@ -188,7 +189,7 @@ public class LdapServerBeanDefinitionParser implements BeanDefinitionParser {
|
||||
}
|
||||
|
||||
private String getDefaultPort() {
|
||||
try (ServerSocket serverSocket = new ServerSocket(DEFAULT_PORT)) {
|
||||
try (ServerSocket serverSocket = new ServerSocket(DEFAULT_PORT, 50, InetAddress.getLoopbackAddress())) {
|
||||
return String.valueOf(serverSocket.getLocalPort());
|
||||
}
|
||||
catch (IOException ex) {
|
||||
|
||||
+31
-2
@@ -22,6 +22,7 @@ import java.security.interfaces.ECPrivateKey;
|
||||
import java.security.interfaces.ECPublicKey;
|
||||
import java.security.interfaces.RSAPrivateKey;
|
||||
import java.security.interfaces.RSAPublicKey;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.Base64;
|
||||
@@ -30,6 +31,7 @@ import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.function.Function;
|
||||
|
||||
import com.nimbusds.jose.jwk.ECKey;
|
||||
import com.nimbusds.jose.jwk.JWK;
|
||||
@@ -47,20 +49,26 @@ import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpMethod;
|
||||
import org.springframework.security.config.Customizer;
|
||||
import org.springframework.security.config.ObjectPostProcessor;
|
||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
|
||||
import org.springframework.security.config.test.SpringTestContext;
|
||||
import org.springframework.security.config.test.SpringTestContextExtension;
|
||||
import org.springframework.security.oauth2.core.OAuth2TokenValidator;
|
||||
import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames;
|
||||
import org.springframework.security.oauth2.jose.TestJwks;
|
||||
import org.springframework.security.oauth2.jose.TestKeys;
|
||||
import org.springframework.security.oauth2.jose.jws.SignatureAlgorithm;
|
||||
import org.springframework.security.oauth2.jwt.DPoPProofContext;
|
||||
import org.springframework.security.oauth2.jwt.DPoPProofJwtDecoderFactory;
|
||||
import org.springframework.security.oauth2.jwt.DPoPProofReplayValidator;
|
||||
import org.springframework.security.oauth2.jwt.JwsHeader;
|
||||
import org.springframework.security.oauth2.jwt.Jwt;
|
||||
import org.springframework.security.oauth2.jwt.JwtClaimsSet;
|
||||
import org.springframework.security.oauth2.jwt.JwtEncoderParameters;
|
||||
import org.springframework.security.oauth2.jwt.NimbusJwtDecoder;
|
||||
import org.springframework.security.oauth2.jwt.NimbusJwtEncoder;
|
||||
import org.springframework.security.oauth2.server.resource.authentication.DPoPAuthenticationProvider;
|
||||
import org.springframework.security.web.SecurityFilterChain;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
@@ -245,8 +253,10 @@ public class DPoPAuthenticationConfigurerTests {
|
||||
.requestMatchers("/resource2").hasAnyAuthority("SCOPE_resource2.read", "SCOPE_resource2.write")
|
||||
.anyRequest().authenticated()
|
||||
)
|
||||
.oauth2ResourceServer((oauth2) -> oauth2
|
||||
.jwt(Customizer.withDefaults()));
|
||||
.oauth2ResourceServer((oauth2ResourceServer) ->
|
||||
oauth2ResourceServer
|
||||
.jwt(Customizer.withDefaults())
|
||||
.withObjectPostProcessor(dPoPProofVerifierFactoryCustomizer()));
|
||||
// @formatter:on
|
||||
return http.build();
|
||||
}
|
||||
@@ -256,6 +266,25 @@ public class DPoPAuthenticationConfigurerTests {
|
||||
return NimbusJwtDecoder.withPublicKey(PROVIDER_RSA_PUBLIC_KEY).build();
|
||||
}
|
||||
|
||||
private ObjectPostProcessor<DPoPAuthenticationProvider> dPoPProofVerifierFactoryCustomizer() {
|
||||
return new ObjectPostProcessor<>() {
|
||||
@Override
|
||||
public <O extends DPoPAuthenticationProvider> O postProcess(O authenticationProvider) {
|
||||
DPoPProofReplayValidator.InMemoryCache inMemoryCache = new DPoPProofReplayValidator.InMemoryCache();
|
||||
inMemoryCache.setMaxSize(50_000);
|
||||
inMemoryCache.setMaxRequestsPerKey(500);
|
||||
DPoPProofReplayValidator dPoPProofReplayValidator = new DPoPProofReplayValidator(inMemoryCache);
|
||||
dPoPProofReplayValidator.setClockSkew(Duration.ofSeconds(60));
|
||||
Function<DPoPProofContext, OAuth2TokenValidator<Jwt>> jwtValidatorFactory = DPoPProofJwtDecoderFactory
|
||||
.createDefaultJwtValidatorFactory(Collections.singletonList(dPoPProofReplayValidator));
|
||||
DPoPProofJwtDecoderFactory dPoPProofJwtDecoderFactory = new DPoPProofJwtDecoderFactory();
|
||||
dPoPProofJwtDecoderFactory.setJwtValidatorFactory(jwtValidatorFactory);
|
||||
authenticationProvider.setDPoPProofVerifierFactory(dPoPProofJwtDecoderFactory);
|
||||
return authenticationProvider;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@RestController
|
||||
|
||||
+4
-1
@@ -32,6 +32,7 @@ import org.springframework.util.Assert;
|
||||
* {@link java.util.ServiceLoader} mechanism when context-propagation is on the classpath.
|
||||
*
|
||||
* @author Steve Riesenberg
|
||||
* @author Tadaya Tsuyukubo
|
||||
* @since 6.5
|
||||
* @see io.micrometer.context.ContextRegistry
|
||||
*/
|
||||
@@ -53,7 +54,9 @@ public final class SecurityContextHolderThreadLocalAccessor implements ThreadLoc
|
||||
@Override
|
||||
public void setValue(SecurityContext securityContext) {
|
||||
Assert.notNull(securityContext, "securityContext cannot be null");
|
||||
SecurityContextHolder.setContext(securityContext);
|
||||
SecurityContext newContext = SecurityContextHolder.createEmptyContext();
|
||||
newContext.setAuthentication(securityContext.getAuthentication());
|
||||
SecurityContextHolder.setContext(newContext);
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
+1
-1
@@ -132,7 +132,7 @@ public class KeyBasedPersistenceTokenService implements TokenService, Initializi
|
||||
// Verification
|
||||
String content = creationTime + ":" + pseudoRandomNumber + ":" + extendedInfo.toString();
|
||||
String expectedSha512Hex = Sha512DigestUtils.shaHex(content + ":" + serverSecret);
|
||||
Assert.isTrue(expectedSha512Hex.equals(sha1Hex), "Key verification failure");
|
||||
Assert.isTrue(Utf8.isEqual(expectedSha512Hex, sha1Hex), "Key verification failure");
|
||||
return new DefaultToken(key, creationTime, extendedInfo.toString());
|
||||
}
|
||||
|
||||
|
||||
+49
-2
@@ -16,11 +16,18 @@
|
||||
|
||||
package org.springframework.security.core.context;
|
||||
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import org.springframework.core.task.support.ContextPropagatingTaskDecorator;
|
||||
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
|
||||
import org.springframework.security.authentication.TestingAuthenticationToken;
|
||||
import org.springframework.security.core.Authentication;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
|
||||
@@ -29,6 +36,7 @@ import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException
|
||||
* Tests for {@link SecurityContextHolderThreadLocalAccessor}.
|
||||
*
|
||||
* @author Steve Riesenberg
|
||||
* @author Tadaya Tsuyukubo
|
||||
*/
|
||||
public class SecurityContextHolderThreadLocalAccessorTests {
|
||||
|
||||
@@ -65,9 +73,11 @@ public class SecurityContextHolderThreadLocalAccessorTests {
|
||||
@Test
|
||||
public void setValueWhenSecurityContextThenSetsSecurityContextHolder() {
|
||||
SecurityContext securityContext = SecurityContextHolder.createEmptyContext();
|
||||
securityContext.setAuthentication(new TestingAuthenticationToken("user", "password"));
|
||||
Authentication authentication = new TestingAuthenticationToken("user", "password");
|
||||
securityContext.setAuthentication(authentication);
|
||||
this.threadLocalAccessor.setValue(securityContext);
|
||||
assertThat(SecurityContextHolder.getContext()).isSameAs(securityContext);
|
||||
assertThat(SecurityContextHolder.getContext()).isNotSameAs(securityContext);
|
||||
assertThat(SecurityContextHolder.getContext().getAuthentication()).isSameAs(authentication);
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -90,4 +100,41 @@ public class SecurityContextHolderThreadLocalAccessorTests {
|
||||
assertThat(SecurityContextHolder.getContext()).isEqualTo(emptyContext);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void newSecurityContextInDifferentThread() throws Exception {
|
||||
Authentication authA = new TestingAuthenticationToken("foo", "password");
|
||||
Authentication authB = new TestingAuthenticationToken("bar", "password");
|
||||
|
||||
SecurityContext securityContext = SecurityContextHolder.createEmptyContext();
|
||||
securityContext.setAuthentication(authA);
|
||||
SecurityContextHolder.setContext(securityContext);
|
||||
|
||||
CountDownLatch latch = new CountDownLatch(1);
|
||||
AtomicReference<SecurityContext> contextHolder = new AtomicReference<>();
|
||||
AtomicReference<Authentication> authHolder = new AtomicReference<>();
|
||||
Runnable runnable = () -> {
|
||||
SecurityContext context = SecurityContextHolder.getContext();
|
||||
contextHolder.set(context);
|
||||
authHolder.set(context.getAuthentication());
|
||||
context.setAuthentication(authB);
|
||||
latch.countDown();
|
||||
};
|
||||
|
||||
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
|
||||
executor.setTaskDecorator(new ContextPropagatingTaskDecorator());
|
||||
executor.afterPropertiesSet();
|
||||
|
||||
executor.execute(runnable);
|
||||
|
||||
boolean finished = latch.await(10, TimeUnit.SECONDS);
|
||||
assertThat(finished).isTrue();
|
||||
|
||||
assertThat(contextHolder.get()).isNotSameAs(securityContext);
|
||||
assertThat(authHolder.get()).isSameAs(authA);
|
||||
|
||||
SecurityContext current = SecurityContextHolder.getContext();
|
||||
assertThat(current).isSameAs(securityContext);
|
||||
assertThat(current.getAuthentication()).isSameAs(authA);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -21,6 +21,9 @@ import java.nio.CharBuffer;
|
||||
import java.nio.charset.CharacterCodingException;
|
||||
import java.nio.charset.Charset;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
|
||||
import org.jspecify.annotations.Nullable;
|
||||
|
||||
/**
|
||||
* UTF-8 Charset encoder/decoder.
|
||||
@@ -66,4 +69,21 @@ public final class Utf8 {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Constant time comparison to prevent against timing attacks.
|
||||
* @param expected the expected {@link CharSequence}
|
||||
* @param actual the actual {@link CharSequence}
|
||||
* @return true if {@code expected} and {@code actual} are equal, false otherwise
|
||||
* @since 5.7.26
|
||||
*/
|
||||
public static boolean isEqual(@Nullable CharSequence expected, @Nullable CharSequence actual) {
|
||||
byte[] expectedBytes = bytesUtf8(expected);
|
||||
byte[] actualBytes = bytesUtf8(actual);
|
||||
return MessageDigest.isEqual(expectedBytes, actualBytes);
|
||||
}
|
||||
|
||||
private static byte @Nullable [] bytesUtf8(@Nullable CharSequence s) {
|
||||
return (s != null) ? Utf8.encode(s) : null;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+2
@@ -37,7 +37,9 @@ import org.springframework.security.crypto.util.EncodingUtils;
|
||||
*
|
||||
* @author Keith Donald
|
||||
* @author Dave Syer
|
||||
* @deprecated Use {@link AesCbcBytesEncryptor} or {@link AesGcmBytesEncryptor} instead.
|
||||
*/
|
||||
@Deprecated
|
||||
public final class AesBytesEncryptor implements BytesEncryptor {
|
||||
|
||||
private final SecretKey secretKey;
|
||||
|
||||
+165
@@ -0,0 +1,165 @@
|
||||
/*
|
||||
* 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.encrypt;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
import javax.crypto.Cipher;
|
||||
import javax.crypto.SecretKey;
|
||||
import javax.crypto.spec.IvParameterSpec;
|
||||
import javax.crypto.spec.PBEKeySpec;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
|
||||
import org.springframework.security.crypto.codec.Hex;
|
||||
import org.springframework.security.crypto.keygen.BytesKeyGenerator;
|
||||
import org.springframework.security.crypto.keygen.KeyGenerators;
|
||||
import org.springframework.security.crypto.util.EncodingUtils;
|
||||
|
||||
/**
|
||||
* {@link BytesEncryptor} that uses 256-bit AES/CBC/PKCS5Padding with a random 16-byte
|
||||
* initialization vector. The IV is prepended to the ciphertext on encrypt and stripped on
|
||||
* decrypt.
|
||||
*
|
||||
* <p>
|
||||
* Note that CBC mode provides confidentiality but <em>not</em> integrity or authenticity.
|
||||
* Applications that require authenticated encryption should prefer
|
||||
* {@link AesGcmBytesEncryptor}. See the <a href=
|
||||
* "https://cheatsheetseries.owasp.org/cheatsheets/Cryptographic_Storage_Cheat_Sheet.html">
|
||||
* OWASP Cryptographic Storage Cheat Sheet</a> for guidance on choosing a cipher mode.
|
||||
*
|
||||
* <p>
|
||||
* When key derivation is used via {@link #withPassword(String, CharSequence)}, the key is
|
||||
* derived using PBKDF2WithHmacSHA256 with {@code DEFAULT_PBKDF2_ITERATIONS} iterations
|
||||
* per the <a href=
|
||||
* "https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html">
|
||||
* OWASP Password Storage Cheat Sheet</a>. Because derivation is intentionally expensive,
|
||||
* the encryptor instance should be created once and reused rather than constructed
|
||||
* per-operation.
|
||||
*
|
||||
* @author Josh Cummings
|
||||
* @since 5.7.26
|
||||
* @see AesGcmBytesEncryptor
|
||||
* @see AesBytesEncryptor
|
||||
*/
|
||||
public final class AesCbcBytesEncryptor implements BytesEncryptor {
|
||||
|
||||
private static final String ALGORITHM = "AES/CBC/PKCS5Padding";
|
||||
|
||||
private static final int IV_LENGTH_BYTES = 16;
|
||||
|
||||
private static final int DEFAULT_PBKDF2_ITERATIONS = 600_000;
|
||||
|
||||
private final SecretKey secretKey;
|
||||
|
||||
private final Cipher encryptor;
|
||||
|
||||
private final Cipher decryptor;
|
||||
|
||||
private final BytesKeyGenerator ivGenerator;
|
||||
|
||||
private AesCbcBytesEncryptor(SecretKey secretKey, BytesKeyGenerator ivGenerator) {
|
||||
this.secretKey = new SecretKeySpec(secretKey.getEncoded(), "AES");
|
||||
this.encryptor = CipherUtils.newCipher(ALGORITHM);
|
||||
this.decryptor = CipherUtils.newCipher(ALGORITHM);
|
||||
this.ivGenerator = ivGenerator;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an encryptor that derives its key from the given password and hex-encoded
|
||||
* salt using PBKDF2WithHmacSHA1.
|
||||
* @param password the password value
|
||||
* @param salt the hex-encoded salt value
|
||||
*/
|
||||
public static Builder withPassword(String password, CharSequence salt) {
|
||||
return new Builder(password, salt);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an encryptor using the supplied {@link SecretKey}.
|
||||
* @param secretKey the secret (symmetric) key
|
||||
*/
|
||||
public static Builder withSecretKey(SecretKey secretKey) {
|
||||
return new Builder(secretKey);
|
||||
}
|
||||
|
||||
@Override
|
||||
public byte[] encrypt(byte[] bytes) {
|
||||
synchronized (this.encryptor) {
|
||||
byte[] iv = this.ivGenerator.generateKey();
|
||||
CipherUtils.initCipher(this.encryptor, Cipher.ENCRYPT_MODE, this.secretKey, new IvParameterSpec(iv));
|
||||
byte[] ciphertext = CipherUtils.doFinal(this.encryptor, bytes);
|
||||
return EncodingUtils.concatenate(iv, ciphertext);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public byte[] decrypt(byte[] encryptedBytes) {
|
||||
int ivLength = this.ivGenerator.getKeyLength();
|
||||
byte[] iv = EncodingUtils.subArray(encryptedBytes, 0, ivLength);
|
||||
byte[] ciphertext = EncodingUtils.subArray(encryptedBytes, ivLength, encryptedBytes.length);
|
||||
synchronized (this.decryptor) {
|
||||
CipherUtils.initCipher(this.decryptor, Cipher.DECRYPT_MODE, this.secretKey, new IvParameterSpec(iv));
|
||||
return CipherUtils.doFinal(this.decryptor, ciphertext);
|
||||
}
|
||||
}
|
||||
|
||||
private static SecretKey deriveKey(String password, CharSequence salt) {
|
||||
return CipherUtils.newSecretKey("PBKDF2WithHmacSHA256",
|
||||
new PBEKeySpec(password.toCharArray(), Hex.decode(salt), DEFAULT_PBKDF2_ITERATIONS, 256));
|
||||
}
|
||||
|
||||
/**
|
||||
* A Builder for {@link AesCbcBytesEncryptor}.
|
||||
*/
|
||||
public static final class Builder {
|
||||
|
||||
private final SecretKey secretKey;
|
||||
|
||||
private BytesKeyGenerator ivGenerator = KeyGenerators.secureRandom(IV_LENGTH_BYTES);
|
||||
|
||||
private Builder(SecretKey secretKey) {
|
||||
this.secretKey = secretKey;
|
||||
}
|
||||
|
||||
private Builder(String password, CharSequence salt) {
|
||||
this.secretKey = deriveKey(password, salt);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the {@link BytesKeyGenerator} to use for generating the initialization
|
||||
* vector.
|
||||
* @param ivGenerator the {@link BytesKeyGenerator} to use for generating the
|
||||
* initialization vector
|
||||
* @return this builder
|
||||
*/
|
||||
public Builder ivGenerator(BytesKeyGenerator ivGenerator) {
|
||||
Objects.requireNonNull(ivGenerator, "ivGenerator cannot be null");
|
||||
this.ivGenerator = ivGenerator;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the {@link AesCbcBytesEncryptor}.
|
||||
* @return the {@link AesCbcBytesEncryptor}
|
||||
*/
|
||||
public AesCbcBytesEncryptor build() {
|
||||
return new AesCbcBytesEncryptor(this.secretKey, this.ivGenerator);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
+172
@@ -0,0 +1,172 @@
|
||||
/*
|
||||
* 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.encrypt;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
import javax.crypto.Cipher;
|
||||
import javax.crypto.SecretKey;
|
||||
import javax.crypto.spec.GCMParameterSpec;
|
||||
import javax.crypto.spec.PBEKeySpec;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
|
||||
import org.springframework.security.crypto.codec.Hex;
|
||||
import org.springframework.security.crypto.keygen.BytesKeyGenerator;
|
||||
import org.springframework.security.crypto.keygen.KeyGenerators;
|
||||
import org.springframework.security.crypto.util.EncodingUtils;
|
||||
|
||||
/**
|
||||
* {@link BytesEncryptor} that uses 256-bit AES/GCM/NoPadding with a random 16-byte
|
||||
* initialization vector and a 128-bit authentication tag. The IV is prepended to the
|
||||
* ciphertext on encrypt and stripped on decrypt. GCM provides authenticated encryption
|
||||
* (AEAD): both confidentiality and integrity are protected, and decryption throws if the
|
||||
* ciphertext has been tampered with.
|
||||
*
|
||||
* <p>
|
||||
* This class uses a 16-byte (128-bit) IV rather than the 12-byte (96-bit) IV recommended
|
||||
* by NIST SP 800-38D for GCM. Both lengths are cryptographically valid; the 16-byte
|
||||
* choice maintains consistency with the rest of the Spring Security crypto module. For
|
||||
* additional guidance, please see the <a href=
|
||||
* "https://cheatsheetseries.owasp.org/cheatsheets/Cryptographic_Storage_Cheat_Sheet.html">
|
||||
* OWASP Cryptographic Storage Cheat Sheet</a>.
|
||||
*
|
||||
* <p>
|
||||
* When key derivation is used via {@link #withPassword(String, CharSequence)}, the key is
|
||||
* derived using PBKDF2WithHmacSHA256 with {@code DEFAULT_PBKDF2_ITERATIONS} iterations
|
||||
* per the <a href=
|
||||
* "https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html">
|
||||
* OWASP Password Storage Cheat Sheet</a>. Because derivation is intentionally expensive,
|
||||
* the encryptor instance should be created once and reused rather than constructed
|
||||
* per-operation.
|
||||
*
|
||||
* @author Josh Cummings
|
||||
* @since 5.7.26
|
||||
* @see AesCbcBytesEncryptor
|
||||
* @see AesBytesEncryptor
|
||||
*/
|
||||
public final class AesGcmBytesEncryptor implements BytesEncryptor {
|
||||
|
||||
private static final String ALGORITHM = "AES/GCM/NoPadding";
|
||||
|
||||
private static final int IV_LENGTH_BYTES = 16;
|
||||
|
||||
private static final int TAG_LENGTH_BITS = 128;
|
||||
|
||||
private static final int DEFAULT_PBKDF2_ITERATIONS = 600_000;
|
||||
|
||||
private final SecretKey secretKey;
|
||||
|
||||
private final Cipher encryptor;
|
||||
|
||||
private final Cipher decryptor;
|
||||
|
||||
private final BytesKeyGenerator ivGenerator;
|
||||
|
||||
private AesGcmBytesEncryptor(SecretKey secretKey, BytesKeyGenerator ivGenerator) {
|
||||
this.secretKey = new SecretKeySpec(secretKey.getEncoded(), "AES");
|
||||
this.encryptor = CipherUtils.newCipher(ALGORITHM);
|
||||
this.decryptor = CipherUtils.newCipher(ALGORITHM);
|
||||
this.ivGenerator = ivGenerator;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an encryptor that derives its key from the given password and hex-encoded
|
||||
* salt using PBKDF2WithHmacSHA1.
|
||||
* @param password the password value
|
||||
* @param salt the hex-encoded salt value
|
||||
*/
|
||||
public static Builder withPassword(String password, CharSequence salt) {
|
||||
return new Builder(password, salt);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an encryptor using the supplied {@link SecretKey}.
|
||||
* @param secretKey the secret (symmetric) key
|
||||
*/
|
||||
public static Builder withSecretKey(SecretKey secretKey) {
|
||||
return new Builder(secretKey);
|
||||
}
|
||||
|
||||
@Override
|
||||
public byte[] encrypt(byte[] bytes) {
|
||||
synchronized (this.encryptor) {
|
||||
byte[] iv = this.ivGenerator.generateKey();
|
||||
CipherUtils.initCipher(this.encryptor, Cipher.ENCRYPT_MODE, this.secretKey,
|
||||
new GCMParameterSpec(TAG_LENGTH_BITS, iv));
|
||||
byte[] ciphertext = CipherUtils.doFinal(this.encryptor, bytes);
|
||||
return EncodingUtils.concatenate(iv, ciphertext);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public byte[] decrypt(byte[] encryptedBytes) {
|
||||
int ivLength = this.ivGenerator.getKeyLength();
|
||||
byte[] iv = EncodingUtils.subArray(encryptedBytes, 0, ivLength);
|
||||
byte[] ciphertext = EncodingUtils.subArray(encryptedBytes, ivLength, encryptedBytes.length);
|
||||
synchronized (this.decryptor) {
|
||||
CipherUtils.initCipher(this.decryptor, Cipher.DECRYPT_MODE, this.secretKey,
|
||||
new GCMParameterSpec(TAG_LENGTH_BITS, iv));
|
||||
return CipherUtils.doFinal(this.decryptor, ciphertext);
|
||||
}
|
||||
}
|
||||
|
||||
private static SecretKey deriveKey(String password, CharSequence salt) {
|
||||
return CipherUtils.newSecretKey("PBKDF2WithHmacSHA256",
|
||||
new PBEKeySpec(password.toCharArray(), Hex.decode(salt), DEFAULT_PBKDF2_ITERATIONS, 256));
|
||||
}
|
||||
|
||||
/**
|
||||
* A Builder for {@link AesGcmBytesEncryptor}.
|
||||
*/
|
||||
public static final class Builder {
|
||||
|
||||
private final SecretKey secretKey;
|
||||
|
||||
private BytesKeyGenerator ivGenerator = KeyGenerators.secureRandom(IV_LENGTH_BYTES);
|
||||
|
||||
private Builder(SecretKey secretKey) {
|
||||
this.secretKey = secretKey;
|
||||
}
|
||||
|
||||
private Builder(String password, CharSequence salt) {
|
||||
this.secretKey = deriveKey(password, salt);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the {@link BytesKeyGenerator} to use for generating the initialization
|
||||
* vector.
|
||||
* @param ivGenerator the {@link BytesKeyGenerator} to use for generating the
|
||||
* initialization vector
|
||||
* @return this builder
|
||||
*/
|
||||
public Builder ivGenerator(BytesKeyGenerator ivGenerator) {
|
||||
Objects.requireNonNull(ivGenerator, "ivGenerator cannot be null");
|
||||
this.ivGenerator = ivGenerator;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the {@link AesGcmBytesEncryptor}.
|
||||
* @return the {@link AesGcmBytesEncryptor}
|
||||
*/
|
||||
public AesGcmBytesEncryptor build() {
|
||||
return new AesGcmBytesEncryptor(this.secretKey, this.ivGenerator);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -16,7 +16,6 @@
|
||||
|
||||
package org.springframework.security.crypto.encrypt;
|
||||
|
||||
import org.springframework.security.crypto.encrypt.AesBytesEncryptor.CipherAlgorithm;
|
||||
import org.springframework.security.crypto.keygen.KeyGenerators;
|
||||
|
||||
/**
|
||||
@@ -42,9 +41,14 @@ public final class Encryptors {
|
||||
* not be shared
|
||||
* @param salt a hex-encoded, random, site-global salt value to use to generate the
|
||||
* key
|
||||
* @deprecated Use {@link AesGcmBytesEncryptor#withPassword(String, CharSequence)}
|
||||
* instead.
|
||||
*/
|
||||
@Deprecated
|
||||
@SuppressWarnings("deprecation")
|
||||
public static BytesEncryptor stronger(CharSequence password, CharSequence salt) {
|
||||
return new AesBytesEncryptor(password.toString(), salt, KeyGenerators.secureRandom(16), CipherAlgorithm.GCM);
|
||||
return new AesBytesEncryptor(password.toString(), salt, KeyGenerators.secureRandom(16),
|
||||
AesBytesEncryptor.CipherAlgorithm.GCM);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -53,18 +57,16 @@ public final class Encryptors {
|
||||
* Function #2). Salts the password to prevent dictionary attacks against the key. The
|
||||
* provided salt is expected to be hex-encoded; it should be random and at least 8
|
||||
* bytes in length. Also applies a random 16-byte initialization vector to ensure each
|
||||
* encrypted message will be unique. Requires Java 6. NOTE: This mode is not
|
||||
* <a href="https://en.wikipedia.org/wiki/Authenticated_encryption">authenticated</a>
|
||||
* and does not provide any guarantees about the authenticity of the data. For a more
|
||||
* secure alternative, users should prefer
|
||||
* {@link #stronger(CharSequence, CharSequence)}.
|
||||
* encrypted message will be unique. Requires Java 6.
|
||||
* @param password the password used to generate the encryptor's secret key; should
|
||||
* not be shared
|
||||
* @param salt a hex-encoded, random, site-global salt value to use to generate the
|
||||
* key
|
||||
*
|
||||
* @see Encryptors#stronger(CharSequence, CharSequence)
|
||||
* @deprecated Use {@link AesCbcBytesEncryptor#withPassword(String, CharSequence)}
|
||||
* instead.
|
||||
*/
|
||||
@Deprecated
|
||||
@SuppressWarnings("deprecation")
|
||||
public static BytesEncryptor standard(CharSequence password, CharSequence salt) {
|
||||
return new AesBytesEncryptor(password.toString(), salt, KeyGenerators.secureRandom(16));
|
||||
}
|
||||
@@ -74,8 +76,12 @@ public final class Encryptors {
|
||||
* text is hex-encoded.
|
||||
* @param password the password used to generate the encryptor's secret key; should
|
||||
* not be shared
|
||||
* @deprecated Use {@link AesGcmBytesEncryptor#withPassword(String, CharSequence)}
|
||||
* instead.
|
||||
* @see Encryptors#stronger(CharSequence, CharSequence)
|
||||
*/
|
||||
@Deprecated
|
||||
@SuppressWarnings("deprecation")
|
||||
public static TextEncryptor delux(CharSequence password, CharSequence salt) {
|
||||
return new HexEncodingTextEncryptor(stronger(password, salt));
|
||||
}
|
||||
@@ -85,8 +91,12 @@ public final class Encryptors {
|
||||
* text is hex-encoded.
|
||||
* @param password the password used to generate the encryptor's secret key; should
|
||||
* not be shared
|
||||
* @deprecated Use {@link AesCbcBytesEncryptor#withPassword(String, CharSequence)}
|
||||
* instead.
|
||||
* @see Encryptors#standard(CharSequence, CharSequence)
|
||||
*/
|
||||
@Deprecated
|
||||
@SuppressWarnings("deprecation")
|
||||
public static TextEncryptor text(CharSequence password, CharSequence salt) {
|
||||
return new HexEncodingTextEncryptor(standard(password, salt));
|
||||
}
|
||||
|
||||
+1
-11
@@ -16,8 +16,6 @@
|
||||
|
||||
package org.springframework.security.crypto.password;
|
||||
|
||||
import java.security.MessageDigest;
|
||||
|
||||
import org.jspecify.annotations.Nullable;
|
||||
|
||||
import org.springframework.security.crypto.codec.Utf8;
|
||||
@@ -39,15 +37,7 @@ final class PasswordEncoderUtils {
|
||||
* @return
|
||||
*/
|
||||
static boolean equals(String expected, @Nullable String actual) {
|
||||
byte[] expectedBytes = bytesUtf8(expected);
|
||||
byte[] actualBytes = bytesUtf8(actual);
|
||||
return MessageDigest.isEqual(expectedBytes, actualBytes);
|
||||
}
|
||||
|
||||
private static byte @Nullable [] bytesUtf8(@Nullable String s) {
|
||||
// need to check if Utf8.encode() runs in constant time (probably not).
|
||||
// This may leak length of string.
|
||||
return (s != null) ? Utf8.encode(s) : null;
|
||||
return Utf8.isEqual(expected, actual);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+2
-1
@@ -24,6 +24,7 @@ import com.password4j.BalloonHashingFunction;
|
||||
import com.password4j.Hash;
|
||||
import com.password4j.Password;
|
||||
|
||||
import org.springframework.security.crypto.codec.Utf8;
|
||||
import org.springframework.security.crypto.password.AbstractValidatingPasswordEncoder;
|
||||
import org.springframework.util.Assert;
|
||||
|
||||
@@ -140,7 +141,7 @@ public class BalloonHashingPassword4jPasswordEncoder extends AbstractValidatingP
|
||||
String expectedHash = parts[1];
|
||||
|
||||
Hash hash = Password.hash(rawPassword).addSalt(salt).with(this.balloonHashingFunction);
|
||||
return expectedHash.equals(hash.getResult());
|
||||
return Utf8.isEqual(expectedHash, hash.getResult());
|
||||
}
|
||||
catch (IllegalArgumentException ex) {
|
||||
// Invalid Base64 encoding
|
||||
|
||||
+2
-1
@@ -24,6 +24,7 @@ import com.password4j.Hash;
|
||||
import com.password4j.PBKDF2Function;
|
||||
import com.password4j.Password;
|
||||
|
||||
import org.springframework.security.crypto.codec.Utf8;
|
||||
import org.springframework.security.crypto.password.AbstractValidatingPasswordEncoder;
|
||||
import org.springframework.util.Assert;
|
||||
|
||||
@@ -138,7 +139,7 @@ public class Pbkdf2Password4jPasswordEncoder extends AbstractValidatingPasswordE
|
||||
String expectedHash = parts[1];
|
||||
|
||||
Hash hash = Password.hash(rawPassword).addSalt(salt).with(this.pbkdf2Function);
|
||||
return expectedHash.equals(hash.getResult());
|
||||
return Utf8.isEqual(expectedHash, hash.getResult());
|
||||
}
|
||||
catch (IllegalArgumentException ex) {
|
||||
// Invalid Base64 encoding
|
||||
|
||||
@@ -37,4 +37,48 @@ public class Utf8Tests {
|
||||
assertThat(decoded).isEqualTo("6048b75ed560785c");
|
||||
}
|
||||
|
||||
@Test
|
||||
public void isEqualWhenDifferentLengthThenFalse() {
|
||||
assertThat(Utf8.isEqual("abc", "a")).isFalse();
|
||||
assertThat(Utf8.isEqual("a", "abc")).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void isEqualWhenNullAndNotEmptyThenFalse() {
|
||||
assertThat(Utf8.isEqual(null, "a")).isFalse();
|
||||
assertThat(Utf8.isEqual("a", null)).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void isEqualWhenNullAndNullThenTrue() {
|
||||
assertThat(Utf8.isEqual(null, null)).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void isEqualWhenNullAndEmptyThenFalse() {
|
||||
assertThat(Utf8.isEqual(null, "")).isFalse();
|
||||
assertThat(Utf8.isEqual("", null)).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void isEqualWhenNotEmptyAndEmptyThenFalse() {
|
||||
assertThat(Utf8.isEqual("abc", "")).isFalse();
|
||||
assertThat(Utf8.isEqual("", "abc")).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void isEqualWhenEmptyAndEmptyThenTrue() {
|
||||
assertThat(Utf8.isEqual("", "")).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void isEqualWhenDifferentCaseThenFalse() {
|
||||
assertThat(Utf8.isEqual("aBc", "abc")).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void isEqualWhenSameThenTrue() {
|
||||
assertThat(Utf8.isEqual("abcdef", "abcdef")).isTrue();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+203
@@ -0,0 +1,203 @@
|
||||
/*
|
||||
* 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.encrypt;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
import javax.crypto.SecretKey;
|
||||
import javax.crypto.spec.PBEKeySpec;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import org.springframework.security.crypto.codec.Hex;
|
||||
import org.springframework.security.crypto.keygen.BytesKeyGenerator;
|
||||
import org.springframework.security.crypto.keygen.KeyGenerators;
|
||||
import org.springframework.security.crypto.password.Pbkdf2PasswordEncoder.SecretKeyFactoryAlgorithm;
|
||||
import org.springframework.security.crypto.util.EncodingUtils;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatIllegalStateException;
|
||||
import static org.mockito.BDDMockito.given;
|
||||
import static org.mockito.Mockito.mock;
|
||||
|
||||
/**
|
||||
* Tests for {@link AesCbcBytesEncryptor}.
|
||||
*/
|
||||
class AesCbcBytesEncryptorTests {
|
||||
|
||||
private final String secret = "value";
|
||||
|
||||
private final String password = "password";
|
||||
|
||||
private final String hexSalt = "deadbeef";
|
||||
|
||||
@Test
|
||||
void roundtripWhenUsingPasswordAndSaltThenEncryptsAndDecrypts() {
|
||||
CryptoAssumptions.assumeCBCJCE();
|
||||
AesCbcBytesEncryptor encryptor = AesCbcBytesEncryptor.withPassword(this.password, this.hexSalt).build();
|
||||
byte[] encrypted = encryptor.encrypt(this.secret.getBytes());
|
||||
assertThat(new String(encryptor.decrypt(encrypted))).isEqualTo(this.secret);
|
||||
}
|
||||
|
||||
@Test
|
||||
void roundtripWhenUsingSecretKeyThenEncryptsAndDecrypts() {
|
||||
CryptoAssumptions.assumeCBCJCE();
|
||||
PBEKeySpec keySpec = new PBEKeySpec(this.password.toCharArray(), Hex.decode(this.hexSalt), 1024, 256);
|
||||
SecretKey secretKey = CipherUtils.newSecretKey(SecretKeyFactoryAlgorithm.PBKDF2WithHmacSHA1.name(), keySpec);
|
||||
AesCbcBytesEncryptor encryptor = AesCbcBytesEncryptor.withSecretKey(secretKey).build();
|
||||
byte[] encrypted = encryptor.encrypt(this.secret.getBytes());
|
||||
assertThat(new String(encryptor.decrypt(encrypted))).isEqualTo(this.secret);
|
||||
}
|
||||
|
||||
@Test
|
||||
void encryptWhenUsingMockIvThenProducesKnownCiphertext() {
|
||||
CryptoAssumptions.assumeCBCJCE();
|
||||
BytesKeyGenerator mockGenerator = mock(BytesKeyGenerator.class);
|
||||
given(mockGenerator.generateKey()).willReturn(Hex.decode("4b0febebd439db7ca77153cb254520c3"));
|
||||
given(mockGenerator.getKeyLength()).willReturn(16);
|
||||
AesCbcBytesEncryptor encryptor = AesCbcBytesEncryptor.withPassword(this.password, this.hexSalt)
|
||||
.ivGenerator(mockGenerator)
|
||||
.build();
|
||||
byte[] encrypted = encryptor.encrypt(this.secret.getBytes());
|
||||
assertThat(Hex.encode(encrypted)).isEqualTo("4b0febebd439db7ca77153cb254520c3b7232ac29355d07869433f1ecf55fe94");
|
||||
assertThat(new String(encryptor.decrypt(encrypted))).isEqualTo(this.secret);
|
||||
}
|
||||
|
||||
@Test
|
||||
void encryptProducesUniqueOutputAndIvIsPrePended() {
|
||||
CryptoAssumptions.assumeCBCJCE();
|
||||
AesCbcBytesEncryptor encryptor = AesCbcBytesEncryptor.withPassword(this.password, this.hexSalt).build();
|
||||
byte[] first = encryptor.encrypt(this.secret.getBytes());
|
||||
byte[] second = encryptor.encrypt(this.secret.getBytes());
|
||||
assertThat(first).isNotEqualTo(second);
|
||||
assertThat(first.length).isGreaterThan(16);
|
||||
}
|
||||
|
||||
@Test
|
||||
@SuppressWarnings("deprecation")
|
||||
void migratesFromDeprecatedNullIvCbcToAesCbcBytesEncryptor() {
|
||||
CryptoAssumptions.assumeCBCJCE();
|
||||
AesBytesEncryptor deprecated = new AesBytesEncryptor(this.password, this.hexSalt);
|
||||
byte[] encrypted = deprecated.encrypt(this.secret.getBytes());
|
||||
|
||||
AesCbcBytesEncryptor modern = AesCbcBytesEncryptor.withPassword(this.password, this.hexSalt).build();
|
||||
BytesEncryptor migrating = new MigratingBytesEncryptor("{CBC}", modern, deprecated);
|
||||
|
||||
assertThat(new String(migrating.decrypt(encrypted))).isEqualTo(this.secret);
|
||||
|
||||
byte[] migrated = migrating.encrypt(this.secret.getBytes());
|
||||
assertThat(migrated[0]).isEqualTo((byte) '{');
|
||||
assertThat(migrated[1]).isEqualTo((byte) 'C');
|
||||
assertThat(migrated[2]).isEqualTo((byte) 'B');
|
||||
assertThat(migrated[3]).isEqualTo((byte) 'C');
|
||||
assertThat(migrated[4]).isEqualTo((byte) '}');
|
||||
assertThat(new String(migrating.decrypt(migrated))).isEqualTo(this.secret);
|
||||
}
|
||||
|
||||
@Test
|
||||
@SuppressWarnings("deprecation")
|
||||
void withSecretWhenAesBytesEncryptorEncryptsThenDecrypts() {
|
||||
CryptoAssumptions.assumeCBCJCE();
|
||||
PBEKeySpec keySpec = new PBEKeySpec(this.password.toCharArray(), Hex.decode(this.hexSalt), 1024, 256);
|
||||
SecretKey key = CipherUtils.newSecretKey(SecretKeyFactoryAlgorithm.PBKDF2WithHmacSHA1.name(), keySpec);
|
||||
AesBytesEncryptor deprecated = new AesBytesEncryptor(key, KeyGenerators.secureRandom(16),
|
||||
AesBytesEncryptor.CipherAlgorithm.CBC);
|
||||
AesCbcBytesEncryptor encryptor = AesCbcBytesEncryptor.withSecretKey(key).build();
|
||||
byte[] encrypted = deprecated.encrypt(this.secret.getBytes());
|
||||
assertThat(new String(encryptor.decrypt(encrypted))).isEqualTo(this.secret);
|
||||
}
|
||||
|
||||
@Test
|
||||
@SuppressWarnings("deprecation")
|
||||
void aesBytesEncryptorWhenEncryptsThenAesCbcBytesEncryptorDecrypts() {
|
||||
CryptoAssumptions.assumeCBCJCE();
|
||||
PBEKeySpec keySpec = new PBEKeySpec(this.password.toCharArray(), Hex.decode(this.hexSalt), 1024, 256);
|
||||
SecretKey key = CipherUtils.newSecretKey(SecretKeyFactoryAlgorithm.PBKDF2WithHmacSHA1.name(), keySpec);
|
||||
AesCbcBytesEncryptor encryptor = AesCbcBytesEncryptor.withSecretKey(key).build();
|
||||
AesBytesEncryptor deprecated = new AesBytesEncryptor(key, KeyGenerators.secureRandom(16),
|
||||
AesBytesEncryptor.CipherAlgorithm.CBC);
|
||||
byte[] encrypted = encryptor.encrypt(this.secret.getBytes());
|
||||
assertThat(new String(deprecated.decrypt(encrypted))).isEqualTo(this.secret);
|
||||
}
|
||||
|
||||
@Test
|
||||
void roundtripWhenUsingCustomIvGeneratorThenEncryptsAndDecrypts() {
|
||||
CryptoAssumptions.assumeCBCJCE();
|
||||
BytesKeyGenerator customIvGenerator = mock(BytesKeyGenerator.class);
|
||||
given(customIvGenerator.generateKey()).willReturn(Hex.decode("4b0febebd439db7ca77153cb254520c3"));
|
||||
given(customIvGenerator.getKeyLength()).willReturn(16);
|
||||
AesCbcBytesEncryptor encryptor = AesCbcBytesEncryptor.withPassword(this.password, this.hexSalt)
|
||||
.ivGenerator(customIvGenerator)
|
||||
.build();
|
||||
byte[] encrypted = encryptor.encrypt(this.secret.getBytes());
|
||||
assertThat(new String(encryptor.decrypt(encrypted))).isEqualTo(this.secret);
|
||||
}
|
||||
|
||||
@Test
|
||||
@SuppressWarnings("deprecation")
|
||||
void withPasswordDerivesADifferentKeyThanAesBytesEncryptor() {
|
||||
CryptoAssumptions.assumeCBCJCE();
|
||||
AesBytesEncryptor deprecated = new AesBytesEncryptor(this.password, this.hexSalt,
|
||||
KeyGenerators.secureRandom(16));
|
||||
AesCbcBytesEncryptor encryptor = AesCbcBytesEncryptor.withPassword(this.password, this.hexSalt).build();
|
||||
byte[] encrypted = deprecated.encrypt(this.secret.getBytes());
|
||||
assertThatIllegalStateException().isThrownBy(() -> encryptor.decrypt(encrypted));
|
||||
}
|
||||
|
||||
private static final class MigratingBytesEncryptor implements BytesEncryptor {
|
||||
|
||||
private final byte[] prefix;
|
||||
|
||||
private final BytesEncryptor write;
|
||||
|
||||
private final BytesEncryptor deprecated;
|
||||
|
||||
MigratingBytesEncryptor(String prefix, BytesEncryptor write, BytesEncryptor deprecated) {
|
||||
this.prefix = prefix.getBytes(StandardCharsets.US_ASCII);
|
||||
this.write = write;
|
||||
this.deprecated = deprecated;
|
||||
}
|
||||
|
||||
@Override
|
||||
public byte[] encrypt(byte[] bytes) {
|
||||
return EncodingUtils.concatenate(this.prefix, this.write.encrypt(bytes));
|
||||
}
|
||||
|
||||
@Override
|
||||
public byte[] decrypt(byte[] encryptedBytes) {
|
||||
if (startsWith(encryptedBytes, this.prefix)) {
|
||||
byte[] bytes = EncodingUtils.subArray(encryptedBytes, this.prefix.length, encryptedBytes.length);
|
||||
return this.write.decrypt(bytes);
|
||||
}
|
||||
return this.deprecated.decrypt(encryptedBytes);
|
||||
}
|
||||
|
||||
private static boolean startsWith(byte[] data, byte[] prefix) {
|
||||
if (data.length < prefix.length) {
|
||||
return false;
|
||||
}
|
||||
for (int i = 0; i < prefix.length; i++) {
|
||||
if (data[i] != prefix[i]) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
+146
@@ -0,0 +1,146 @@
|
||||
/*
|
||||
* 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.encrypt;
|
||||
|
||||
import javax.crypto.SecretKey;
|
||||
import javax.crypto.spec.PBEKeySpec;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import org.springframework.security.crypto.codec.Hex;
|
||||
import org.springframework.security.crypto.keygen.BytesKeyGenerator;
|
||||
import org.springframework.security.crypto.keygen.KeyGenerators;
|
||||
import org.springframework.security.crypto.password.Pbkdf2PasswordEncoder.SecretKeyFactoryAlgorithm;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatIllegalStateException;
|
||||
import static org.mockito.BDDMockito.given;
|
||||
import static org.mockito.Mockito.mock;
|
||||
|
||||
/**
|
||||
* Tests for {@link AesGcmBytesEncryptor}.
|
||||
*/
|
||||
class AesGcmBytesEncryptorTests {
|
||||
|
||||
private final String secret = "value";
|
||||
|
||||
private final String password = "password";
|
||||
|
||||
private final String hexSalt = "deadbeef";
|
||||
|
||||
@Test
|
||||
void roundtripWhenUsingPasswordAndSaltThenEncryptsAndDecrypts() {
|
||||
CryptoAssumptions.assumeGCMJCE();
|
||||
AesGcmBytesEncryptor encryptor = AesGcmBytesEncryptor.withPassword(this.password, this.hexSalt).build();
|
||||
byte[] encrypted = encryptor.encrypt(this.secret.getBytes());
|
||||
assertThat(new String(encryptor.decrypt(encrypted))).isEqualTo(this.secret);
|
||||
}
|
||||
|
||||
@Test
|
||||
void roundtripWhenUsingSecretKeyThenEncryptsAndDecrypts() {
|
||||
CryptoAssumptions.assumeGCMJCE();
|
||||
PBEKeySpec keySpec = new PBEKeySpec(this.password.toCharArray(), Hex.decode(this.hexSalt), 1024, 256);
|
||||
SecretKey secretKey = CipherUtils.newSecretKey(SecretKeyFactoryAlgorithm.PBKDF2WithHmacSHA1.name(), keySpec);
|
||||
AesGcmBytesEncryptor encryptor = AesGcmBytesEncryptor.withSecretKey(secretKey).build();
|
||||
byte[] encrypted = encryptor.encrypt(this.secret.getBytes());
|
||||
assertThat(new String(encryptor.decrypt(encrypted))).isEqualTo(this.secret);
|
||||
}
|
||||
|
||||
@Test
|
||||
void encryptWhenUsingMockIvThenProducesKnownCiphertext() {
|
||||
CryptoAssumptions.assumeGCMJCE();
|
||||
BytesKeyGenerator mockGenerator = mock(BytesKeyGenerator.class);
|
||||
given(mockGenerator.generateKey()).willReturn(Hex.decode("4b0febebd439db7ca77153cb254520c3"));
|
||||
given(mockGenerator.getKeyLength()).willReturn(16);
|
||||
AesGcmBytesEncryptor encryptor = AesGcmBytesEncryptor.withPassword(this.password, this.hexSalt)
|
||||
.ivGenerator(mockGenerator)
|
||||
.build();
|
||||
byte[] encrypted = encryptor.encrypt(this.secret.getBytes());
|
||||
assertThat(Hex.encode(encrypted))
|
||||
.isEqualTo("4b0febebd439db7ca77153cb254520c3e4d61ae38207b4e42b820d311dc3d4e0e2f37ed5ee");
|
||||
assertThat(new String(encryptor.decrypt(encrypted))).isEqualTo(this.secret);
|
||||
}
|
||||
|
||||
@Test
|
||||
void encryptProducesUniqueOutputAndIvIsPrepended() {
|
||||
CryptoAssumptions.assumeGCMJCE();
|
||||
AesGcmBytesEncryptor encryptor = AesGcmBytesEncryptor.withPassword(this.password, this.hexSalt).build();
|
||||
byte[] first = encryptor.encrypt(this.secret.getBytes());
|
||||
byte[] second = encryptor.encrypt(this.secret.getBytes());
|
||||
assertThat(first).isNotEqualTo(second);
|
||||
assertThat(first.length).isGreaterThan(32);
|
||||
}
|
||||
|
||||
@Test
|
||||
@SuppressWarnings("deprecation")
|
||||
void withSecretWhenAesBytesEncryptorEncryptsThenDecrypts() {
|
||||
CryptoAssumptions.assumeGCMJCE();
|
||||
PBEKeySpec keySpec = new PBEKeySpec(this.password.toCharArray(), Hex.decode(this.hexSalt), 1024, 256);
|
||||
SecretKey key = CipherUtils.newSecretKey(SecretKeyFactoryAlgorithm.PBKDF2WithHmacSHA1.name(), keySpec);
|
||||
AesBytesEncryptor deprecated = new AesBytesEncryptor(key, KeyGenerators.secureRandom(16),
|
||||
AesBytesEncryptor.CipherAlgorithm.GCM);
|
||||
AesGcmBytesEncryptor encryptor = AesGcmBytesEncryptor.withSecretKey(key).build();
|
||||
byte[] encrypted = deprecated.encrypt(this.secret.getBytes());
|
||||
assertThat(new String(encryptor.decrypt(encrypted))).isEqualTo(this.secret);
|
||||
}
|
||||
|
||||
@Test
|
||||
@SuppressWarnings("deprecation")
|
||||
void aesBytesEncryptorWhenEncryptsThenAesGcmBytesEncryptorDecrypts() {
|
||||
CryptoAssumptions.assumeGCMJCE();
|
||||
PBEKeySpec keySpec = new PBEKeySpec(this.password.toCharArray(), Hex.decode(this.hexSalt), 1024, 256);
|
||||
SecretKey key = CipherUtils.newSecretKey(SecretKeyFactoryAlgorithm.PBKDF2WithHmacSHA1.name(), keySpec);
|
||||
AesGcmBytesEncryptor encryptor = AesGcmBytesEncryptor.withSecretKey(key)
|
||||
.ivGenerator(KeyGenerators.secureRandom(12))
|
||||
.build();
|
||||
AesBytesEncryptor deprecated = new AesBytesEncryptor(key, KeyGenerators.secureRandom(12),
|
||||
AesBytesEncryptor.CipherAlgorithm.GCM);
|
||||
byte[] encrypted = encryptor.encrypt(this.secret.getBytes());
|
||||
assertThat(new String(deprecated.decrypt(encrypted))).isEqualTo(this.secret);
|
||||
}
|
||||
|
||||
@Test
|
||||
void roundtripWhenUsingCustomIvGeneratorLengthThenEncryptsAndDecrypts() {
|
||||
CryptoAssumptions.assumeGCMJCE();
|
||||
AesGcmBytesEncryptor encryptor = AesGcmBytesEncryptor.withPassword(this.password, this.hexSalt)
|
||||
.ivGenerator(KeyGenerators.secureRandom(12))
|
||||
.build();
|
||||
byte[] encrypted = encryptor.encrypt(this.secret.getBytes());
|
||||
assertThat(new String(encryptor.decrypt(encrypted))).isEqualTo(this.secret);
|
||||
}
|
||||
|
||||
@Test
|
||||
@SuppressWarnings("deprecation")
|
||||
void withPasswordDerivesADifferentKeyThanAesBytesEncryptor() {
|
||||
CryptoAssumptions.assumeGCMJCE();
|
||||
AesBytesEncryptor deprecated = new AesBytesEncryptor(this.password, this.hexSalt,
|
||||
KeyGenerators.secureRandom(16), AesBytesEncryptor.CipherAlgorithm.GCM);
|
||||
AesGcmBytesEncryptor encryptor = AesGcmBytesEncryptor.withPassword(this.password, this.hexSalt).build();
|
||||
byte[] encrypted = deprecated.encrypt(this.secret.getBytes());
|
||||
assertThatIllegalStateException().isThrownBy(() -> encryptor.decrypt(encrypted));
|
||||
}
|
||||
|
||||
@Test
|
||||
void decryptDetectsAuthenticationTagTampering() {
|
||||
CryptoAssumptions.assumeGCMJCE();
|
||||
AesGcmBytesEncryptor encryptor = AesGcmBytesEncryptor.withPassword(this.password, this.hexSalt).build();
|
||||
byte[] encrypted = encryptor.encrypt(this.secret.getBytes());
|
||||
encrypted[17] ^= 0xFF;
|
||||
assertThatIllegalStateException().isThrownBy(() -> encryptor.decrypt(encrypted));
|
||||
}
|
||||
|
||||
}
|
||||
+4
-6
@@ -24,25 +24,23 @@ import javax.crypto.NoSuchPaddingException;
|
||||
import org.junit.jupiter.api.Assumptions;
|
||||
import org.opentest4j.TestAbortedException;
|
||||
|
||||
import org.springframework.security.crypto.encrypt.AesBytesEncryptor.CipherAlgorithm;
|
||||
|
||||
public final class CryptoAssumptions {
|
||||
|
||||
private CryptoAssumptions() {
|
||||
}
|
||||
|
||||
public static void assumeGCMJCE() {
|
||||
assumeAes256(CipherAlgorithm.GCM);
|
||||
assumeAes256("GCM");
|
||||
}
|
||||
|
||||
public static void assumeCBCJCE() {
|
||||
assumeAes256(CipherAlgorithm.CBC);
|
||||
assumeAes256("CBC");
|
||||
}
|
||||
|
||||
private static void assumeAes256(CipherAlgorithm cipherAlgorithm) {
|
||||
private static void assumeAes256(String cipherAlgorithm) {
|
||||
boolean aes256Available = false;
|
||||
try {
|
||||
Cipher.getInstance(cipherAlgorithm.toString());
|
||||
Cipher.getInstance(cipherAlgorithm);
|
||||
aes256Available = Cipher.getMaxAllowedKeyLength("AES") >= 256;
|
||||
}
|
||||
catch (NoSuchAlgorithmException ex) {
|
||||
|
||||
@@ -8,8 +8,8 @@ The code is distributed as part of the core module but has no dependencies on an
|
||||
|
||||
[[spring-security-crypto-encryption]]
|
||||
== Encryptors
|
||||
The javadoc:org.springframework.security.crypto.encrypt.Encryptors[] class provides factory methods for constructing symmetric encryptors.
|
||||
This class lets you create javadoc:org.springframework.security.crypto.encrypt.BytesEncryptor[] instances to encrypt data in raw `byte[]` form.
|
||||
Spring Security provides javadoc:org.springframework.security.crypto.encrypt.AesGcmBytesEncryptor[] and javadoc:org.springframework.security.crypto.encrypt.AesCbcBytesEncryptor[] for constructing symmetric encryptors.
|
||||
These can be used to encrypt data in raw `byte[]` form.
|
||||
You can also construct javadoc:org.springframework.security.crypto.encrypt.TextEncryptor[] instances to encrypt text strings.
|
||||
Encryptors are thread-safe.
|
||||
|
||||
@@ -20,7 +20,7 @@ Both `BytesEncryptor` and `TextEncryptor` are interfaces. `BytesEncryptor` has m
|
||||
|
||||
[[spring-security-crypto-encryption-bytes]]
|
||||
=== BytesEncryptor
|
||||
You can use the `Encryptors.stronger` factory method to construct a `BytesEncryptor`:
|
||||
Use `AesGcmBytesEncryptor` to construct a BytesEncryptor with authenticated encryption:
|
||||
|
||||
.BytesEncryptor
|
||||
[tabs]
|
||||
@@ -29,24 +29,22 @@ Java::
|
||||
+
|
||||
[source,java,role="primary"]
|
||||
----
|
||||
Encryptors.stronger("password", "salt");
|
||||
AesGcmBytesEncryptor.withPassword("password", "salt").build();
|
||||
----
|
||||
|
||||
Kotlin::
|
||||
+
|
||||
[source,kotlin,role="secondary"]
|
||||
----
|
||||
Encryptors.stronger("password", "salt")
|
||||
AesGcmBytesEncryptor.withPassword("password", "salt").build()
|
||||
----
|
||||
======
|
||||
|
||||
The `stronger` encryption method creates an encryptor by using 256-bit AES encryption with
|
||||
Galois Counter Mode (GCM).
|
||||
It derives the secret key by using PKCS #5's PBKDF2 (Password-Based Key Derivation Function #2).
|
||||
This method requires Java 6.
|
||||
The password used to generate the `SecretKey` should be kept in a secure place and should not be shared.
|
||||
The salt is used to prevent dictionary attacks against the key in the event that your encrypted data is compromised.
|
||||
A 16-byte random initialization vector is also applied so that each encrypted message is unique.
|
||||
`AesGcmBytesEncryptor` uses 256-bit AES encryption with Galois Counter Mode (GCM), providing https://en.wikipedia.org/wiki/Authenticated_encryption[authenticated encryption] (AEAD).
|
||||
It derives the secret key using PKCS #5's PBKDF2 (Password-Based Key Derivation Function #2).
|
||||
The password used to generate the SecretKey should be kept in a secure place and not be shared.
|
||||
The salt is used to prevent dictionary attacks against the key in the event your encrypted data is compromised.
|
||||
A 16-byte random initialization vector is also applied so each encrypted message is unique.
|
||||
|
||||
The provided salt should be in hex-encoded String form, be random, and be at least 8 bytes in length.
|
||||
You can generate such a salt by using a `KeyGenerator`:
|
||||
@@ -69,14 +67,33 @@ val salt = KeyGenerators.string().generateKey() // generates a random 8-byte sal
|
||||
----
|
||||
======
|
||||
|
||||
You can also use the `standard` encryption method, which is 256-bit AES in Cipher Block Chaining (CBC) Mode.
|
||||
Users who require CBC mode may use `AesCbcBytesEncryptor`:
|
||||
|
||||
.AesCbcBytesEncryptor
|
||||
[tabs]
|
||||
======
|
||||
Java::
|
||||
+
|
||||
[source,java,role="primary"]
|
||||
----
|
||||
AesCbcBytesEncryptor.withPassword("password", "salt").build();
|
||||
----
|
||||
|
||||
Kotlin::
|
||||
+
|
||||
[source,kotlin,role="secondary"]
|
||||
----
|
||||
AesCbcBytesEncryptor.withPassword("password", "salt").build()
|
||||
----
|
||||
======
|
||||
|
||||
This mode is not https://en.wikipedia.org/wiki/Authenticated_encryption[authenticated] and does not provide any
|
||||
guarantees about the authenticity of the data.
|
||||
For a more secure alternative, use `Encryptors.stronger`.
|
||||
For a more secure alternative, users should prefer `AesGcmBytesEncryptor`.
|
||||
|
||||
[[spring-security-crypto-encryption-text]]
|
||||
=== TextEncryptor
|
||||
You can use the `Encryptors.text` factory method to construct a standard TextEncryptor:
|
||||
Use `AesCbcBytesEncryptor` to encrypt text data:
|
||||
|
||||
.TextEncryptor
|
||||
[tabs]
|
||||
@@ -85,19 +102,22 @@ Java::
|
||||
+
|
||||
[source,java,role="primary"]
|
||||
----
|
||||
Encryptors.text("password", "salt");
|
||||
AesCbcBytesEncryptor.withPassword("password", "salt").build();
|
||||
----
|
||||
|
||||
Kotlin::
|
||||
+
|
||||
[source,kotlin,role="secondary"]
|
||||
----
|
||||
Encryptors.text("password", "salt")
|
||||
AesCbcBytesEncryptor.withPassword("password", "salt").build()
|
||||
----
|
||||
======
|
||||
|
||||
A `TextEncryptor` uses a standard `BytesEncryptor` to encrypt text data.
|
||||
Encrypted results are returned as hex-encoded strings for easy storage on the filesystem or in a database.
|
||||
`AesCbcBytesEncryptor` encrypts data as raw bytes.
|
||||
You can hex-encode the result for easy storage on the filesystem or in the database.
|
||||
|
||||
NOTE: Queryable text encryption (encrypting such that the same plaintext always produces the same ciphertext) is no longer recommended, as it relies on a fixed initialization vector and does not provide adequate security.
|
||||
Instead, look to your data store for a mechanism to query encrypted data.
|
||||
|
||||
[[spring-security-crypto-keygenerators]]
|
||||
== Key Generators
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
[versions]
|
||||
com-squareup-okhttp3 = "3.14.9"
|
||||
io-micrometer = "1.16.6"
|
||||
io-micrometer = "1.16.7"
|
||||
io-rsocket = "1.1.5"
|
||||
io-spring-javaformat = "0.0.47"
|
||||
io-spring-javaformat = "0.0.48"
|
||||
io-spring-nohttp = "0.0.11"
|
||||
jakarta-websocket = "2.2.0"
|
||||
org-apache-maven-resolver = "1.9.27"
|
||||
@@ -13,7 +13,7 @@ org-jetbrains-kotlin = "2.2.21"
|
||||
org-jetbrains-kotlinx = "1.10.2"
|
||||
org-mockito = "5.17.0"
|
||||
org-opensaml5 = "5.1.6"
|
||||
org-springframework = "7.0.8"
|
||||
org-springframework = "7.0.9"
|
||||
com-password4j = "1.8.4"
|
||||
|
||||
[libraries]
|
||||
@@ -32,7 +32,7 @@ io-micrometer-context-propagation = "io.micrometer:context-propagation:1.1.4"
|
||||
io-micrometer-micrometer-bom = { module = "io.micrometer:micrometer-bom", version.ref = "io-micrometer" }
|
||||
io-micrometer-micrometer-observation = { module = "io.micrometer:micrometer-observation", version.ref = "io-micrometer" }
|
||||
io-mockk = "io.mockk:mockk:1.14.11"
|
||||
io-projectreactor-reactor-bom = "io.projectreactor:reactor-bom:2025.0.6"
|
||||
io-projectreactor-reactor-bom = "io.projectreactor:reactor-bom:2025.0.7"
|
||||
io-rsocket-rsocket-bom = { module = "io.rsocket:rsocket-bom", version.ref = "io-rsocket" }
|
||||
io-spring-javaformat-spring-javaformat-checkstyle = { module = "io.spring.javaformat:spring-javaformat-checkstyle", version.ref = "io-spring-javaformat" }
|
||||
io-spring-javaformat-spring-javaformat-gradle-plugin = { module = "io.spring.javaformat:spring-javaformat-gradle-plugin", version.ref = "io-spring-javaformat" }
|
||||
@@ -83,8 +83,8 @@ org-seleniumhq-selenium-selenium-support = "org.seleniumhq.selenium:selenium-sup
|
||||
org-skyscreamer-jsonassert = "org.skyscreamer:jsonassert:1.5.3"
|
||||
org-slf4j-log4j-over-slf4j = "org.slf4j:log4j-over-slf4j:1.7.36"
|
||||
org-slf4j-slf4j-api = "org.slf4j:slf4j-api:2.0.18"
|
||||
org-springframework-data-spring-data-bom = "org.springframework.data:spring-data-bom:2025.1.6"
|
||||
org-springframework-ldap-spring-ldap-core = "org.springframework.ldap:spring-ldap-core:4.0.4"
|
||||
org-springframework-data-spring-data-bom = "org.springframework.data:spring-data-bom:2025.1.7"
|
||||
org-springframework-ldap-spring-ldap-core = "org.springframework.ldap:spring-ldap-core:4.0.5"
|
||||
org-springframework-spring-framework-bom = { module = "org.springframework:spring-framework-bom", version.ref = "org-springframework" }
|
||||
org-synchronoss-cloud-nio-multipart-parser = "org.synchronoss.cloud:nio-multipart-parser:1.1.0"
|
||||
tools-jackson-jackson-bom = "tools.jackson:jackson-bom:3.0.4"
|
||||
|
||||
+20
@@ -21,9 +21,11 @@ import java.net.ServerSocket;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import com.unboundid.ldap.listener.InMemoryDirectoryServer;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import org.springframework.context.support.GenericApplicationContext;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
@@ -48,6 +50,24 @@ public class UnboundIdContainerTests {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void startLdapServerThenListenerBindsToLoopbackAddressOnly() throws Exception {
|
||||
UnboundIdContainer server = new UnboundIdContainer("dc=springframework,dc=org", null);
|
||||
server.setApplicationContext(new GenericApplicationContext());
|
||||
List<Integer> ports = getDefaultPorts(1);
|
||||
server.setPort(ports.get(0));
|
||||
|
||||
try {
|
||||
server.afterPropertiesSet();
|
||||
InMemoryDirectoryServer directoryServer = (InMemoryDirectoryServer) ReflectionTestUtils.getField(server,
|
||||
"directoryServer");
|
||||
assertThat(directoryServer.getListenAddress().isLoopbackAddress()).isTrue();
|
||||
}
|
||||
finally {
|
||||
server.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void afterPropertiesSetWhenPortIsZeroThenRandomPortIsSelected() throws Exception {
|
||||
UnboundIdContainer server = new UnboundIdContainer("dc=springframework,dc=org", null);
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
package org.springframework.security.ldap.server;
|
||||
|
||||
import java.io.InputStream;
|
||||
import java.net.InetAddress;
|
||||
|
||||
import com.unboundid.ldap.listener.InMemoryDirectoryServer;
|
||||
import com.unboundid.ldap.listener.InMemoryDirectoryServerConfig;
|
||||
@@ -96,7 +97,8 @@ public class UnboundIdContainer
|
||||
try {
|
||||
InMemoryDirectoryServerConfig config = new InMemoryDirectoryServerConfig(this.defaultPartitionSuffix);
|
||||
config.addAdditionalBindCredentials("uid=admin,ou=system", "secret");
|
||||
config.setListenerConfigs(InMemoryListenerConfig.createLDAPConfig("LDAP", this.port));
|
||||
config.setListenerConfigs(
|
||||
InMemoryListenerConfig.createLDAPConfig("LDAP", InetAddress.getLoopbackAddress(), this.port, null));
|
||||
config.setEnforceSingleStructuralObjectClass(false);
|
||||
config.setEnforceAttributeSyntaxCompliance(true);
|
||||
DN dn = new DN(this.defaultPartitionSuffix);
|
||||
|
||||
+8
-7
@@ -24,6 +24,7 @@ import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
import org.springframework.lang.Nullable;
|
||||
import org.springframework.security.crypto.codec.Utf8;
|
||||
import org.springframework.security.oauth2.core.OAuth2AccessToken;
|
||||
import org.springframework.security.oauth2.core.OAuth2DeviceCode;
|
||||
import org.springframework.security.oauth2.core.OAuth2RefreshToken;
|
||||
@@ -192,38 +193,38 @@ public final class InMemoryOAuth2AuthorizationService implements OAuth2Authoriza
|
||||
}
|
||||
|
||||
private static boolean matchesState(OAuth2Authorization authorization, String token) {
|
||||
return token.equals(authorization.getAttribute(OAuth2ParameterNames.STATE));
|
||||
return Utf8.isEqual(authorization.getAttribute(OAuth2ParameterNames.STATE), token);
|
||||
}
|
||||
|
||||
private static boolean matchesAuthorizationCode(OAuth2Authorization authorization, String token) {
|
||||
OAuth2Authorization.Token<OAuth2AuthorizationCode> authorizationCode = authorization
|
||||
.getToken(OAuth2AuthorizationCode.class);
|
||||
return authorizationCode != null && authorizationCode.getToken().getTokenValue().equals(token);
|
||||
return authorizationCode != null && Utf8.isEqual(authorizationCode.getToken().getTokenValue(), token);
|
||||
}
|
||||
|
||||
private static boolean matchesAccessToken(OAuth2Authorization authorization, String token) {
|
||||
OAuth2Authorization.Token<OAuth2AccessToken> accessToken = authorization.getToken(OAuth2AccessToken.class);
|
||||
return accessToken != null && accessToken.getToken().getTokenValue().equals(token);
|
||||
return accessToken != null && Utf8.isEqual(accessToken.getToken().getTokenValue(), token);
|
||||
}
|
||||
|
||||
private static boolean matchesRefreshToken(OAuth2Authorization authorization, String token) {
|
||||
OAuth2Authorization.Token<OAuth2RefreshToken> refreshToken = authorization.getToken(OAuth2RefreshToken.class);
|
||||
return refreshToken != null && refreshToken.getToken().getTokenValue().equals(token);
|
||||
return refreshToken != null && Utf8.isEqual(refreshToken.getToken().getTokenValue(), token);
|
||||
}
|
||||
|
||||
private static boolean matchesIdToken(OAuth2Authorization authorization, String token) {
|
||||
OAuth2Authorization.Token<OidcIdToken> idToken = authorization.getToken(OidcIdToken.class);
|
||||
return idToken != null && idToken.getToken().getTokenValue().equals(token);
|
||||
return idToken != null && Utf8.isEqual(idToken.getToken().getTokenValue(), token);
|
||||
}
|
||||
|
||||
private static boolean matchesDeviceCode(OAuth2Authorization authorization, String token) {
|
||||
OAuth2Authorization.Token<OAuth2DeviceCode> deviceCode = authorization.getToken(OAuth2DeviceCode.class);
|
||||
return deviceCode != null && deviceCode.getToken().getTokenValue().equals(token);
|
||||
return deviceCode != null && Utf8.isEqual(deviceCode.getToken().getTokenValue(), token);
|
||||
}
|
||||
|
||||
private static boolean matchesUserCode(OAuth2Authorization authorization, String token) {
|
||||
OAuth2Authorization.Token<OAuth2UserCode> userCode = authorization.getToken(OAuth2UserCode.class);
|
||||
return userCode != null && userCode.getToken().getTokenValue().equals(token);
|
||||
return userCode != null && Utf8.isEqual(userCode.getToken().getTokenValue(), token);
|
||||
}
|
||||
|
||||
@SuppressWarnings("serial")
|
||||
|
||||
+20
-10
@@ -29,6 +29,7 @@ import org.springframework.http.MediaType;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames;
|
||||
import org.springframework.security.oauth2.core.oidc.OidcScopes;
|
||||
import org.springframework.web.util.HtmlUtils;
|
||||
|
||||
/**
|
||||
* For internal use only.
|
||||
@@ -56,6 +57,12 @@ final class DefaultConsentPage {
|
||||
private static String generateConsentPage(HttpServletRequest request, String clientId, Authentication principal,
|
||||
Set<String> requestedScopes, Set<String> authorizedScopes, String state,
|
||||
Map<String, String> additionalParameters) {
|
||||
|
||||
String encodedClientId = HtmlUtils.htmlEscape(clientId);
|
||||
String encodedState = HtmlUtils.htmlEscape(state);
|
||||
String encodedPrincipalName = HtmlUtils.htmlEscape(principal.getName());
|
||||
String encodedRequestUri = HtmlUtils.htmlEscape(request.getRequestURI());
|
||||
|
||||
Set<String> scopesToAuthorize = new HashSet<>();
|
||||
Set<String> scopesPreviouslyAuthorized = new HashSet<>();
|
||||
for (String scope : requestedScopes) {
|
||||
@@ -74,6 +81,7 @@ final class DefaultConsentPage {
|
||||
// the "user_code" being displayed on the device to confirm they are
|
||||
// authorizing the correct device.
|
||||
String userCode = additionalParameters.get(OAuth2ParameterNames.USER_CODE);
|
||||
String encodedUserCode = (userCode != null) ? HtmlUtils.htmlEscape(userCode) : null;
|
||||
|
||||
// @formatter:off
|
||||
StringBuilder builder = new StringBuilder();
|
||||
@@ -98,13 +106,13 @@ final class DefaultConsentPage {
|
||||
builder.append(" </div>");
|
||||
builder.append(" <div class=\"row\">");
|
||||
builder.append(" <div class=\"col text-center\">");
|
||||
builder.append(" <p><span class=\"font-weight-bold text-primary\">" + clientId + "</span> wants to access your account <span class=\"font-weight-bold\">" + principal.getName() + "</span></p>");
|
||||
builder.append(" <p><span class=\"font-weight-bold text-primary\">" + encodedClientId + "</span> wants to access your account <span class=\"font-weight-bold\">" + encodedPrincipalName + "</span></p>");
|
||||
builder.append(" </div>");
|
||||
builder.append(" </div>");
|
||||
if (userCode != null) {
|
||||
builder.append(" <div class=\"row\">");
|
||||
builder.append(" <div class=\"col text-center\">");
|
||||
builder.append(" <p class=\"alert alert-warning\">You have provided the code <span class=\"font-weight-bold\">" + userCode + "</span>. Verify that this code matches what is shown on your device.</p>");
|
||||
builder.append(" <p class=\"alert alert-warning\">You have provided the code <span class=\"font-weight-bold\">" + encodedUserCode + "</span>. Verify that this code matches what is shown on your device.</p>");
|
||||
builder.append(" </div>");
|
||||
builder.append(" </div>");
|
||||
}
|
||||
@@ -115,26 +123,28 @@ final class DefaultConsentPage {
|
||||
builder.append(" </div>");
|
||||
builder.append(" <div class=\"row\">");
|
||||
builder.append(" <div class=\"col text-center\">");
|
||||
builder.append(" <form name=\"consent_form\" method=\"post\" action=\"" + request.getRequestURI() + "\">");
|
||||
builder.append(" <input type=\"hidden\" name=\"client_id\" value=\"" + clientId + "\">");
|
||||
builder.append(" <input type=\"hidden\" name=\"state\" value=\"" + state + "\">");
|
||||
builder.append(" <form name=\"consent_form\" method=\"post\" action=\"" + encodedRequestUri + "\">");
|
||||
builder.append(" <input type=\"hidden\" name=\"client_id\" value=\"" + encodedClientId + "\">");
|
||||
builder.append(" <input type=\"hidden\" name=\"state\" value=\"" + encodedState + "\">");
|
||||
if (userCode != null) {
|
||||
builder.append(" <input type=\"hidden\" name=\"user_code\" value=\"" + userCode + "\">");
|
||||
builder.append(" <input type=\"hidden\" name=\"user_code\" value=\"" + encodedUserCode + "\">");
|
||||
}
|
||||
|
||||
for (String scope : scopesToAuthorize) {
|
||||
String encodedScope = HtmlUtils.htmlEscape(scope);
|
||||
builder.append(" <div class=\"form-group form-check py-1\">");
|
||||
builder.append(" <input class=\"form-check-input\" type=\"checkbox\" name=\"scope\" value=\"" + scope + "\" id=\"" + scope + "\">");
|
||||
builder.append(" <label class=\"form-check-label\" for=\"" + scope + "\">" + scope + "</label>");
|
||||
builder.append(" <input class=\"form-check-input\" type=\"checkbox\" name=\"scope\" value=\"" + encodedScope + "\" id=\"" + encodedScope + "\">");
|
||||
builder.append(" <label class=\"form-check-label\" for=\"" + encodedScope + "\">" + encodedScope + "</label>");
|
||||
builder.append(" </div>");
|
||||
}
|
||||
|
||||
if (!scopesPreviouslyAuthorized.isEmpty()) {
|
||||
builder.append(" <p>You have already granted the following permissions to the above app:</p>");
|
||||
for (String scope : scopesPreviouslyAuthorized) {
|
||||
String encodedScope = HtmlUtils.htmlEscape(scope);
|
||||
builder.append(" <div class=\"form-group form-check py-1\">");
|
||||
builder.append(" <input class=\"form-check-input\" type=\"checkbox\" name=\"scope\" id=\"" + scope + "\" checked disabled>");
|
||||
builder.append(" <label class=\"form-check-label\" for=\"" + scope + "\">" + scope + "</label>");
|
||||
builder.append(" <input class=\"form-check-input\" type=\"checkbox\" name=\"scope\" id=\"" + encodedScope + "\" checked disabled>");
|
||||
builder.append(" <label class=\"form-check-label\" for=\"" + encodedScope + "\">" + encodedScope + "</label>");
|
||||
builder.append(" </div>");
|
||||
}
|
||||
}
|
||||
|
||||
+44
@@ -25,7 +25,9 @@ import org.junit.jupiter.api.Test;
|
||||
|
||||
import org.springframework.security.oauth2.core.AuthorizationGrantType;
|
||||
import org.springframework.security.oauth2.core.OAuth2AccessToken;
|
||||
import org.springframework.security.oauth2.core.OAuth2DeviceCode;
|
||||
import org.springframework.security.oauth2.core.OAuth2RefreshToken;
|
||||
import org.springframework.security.oauth2.core.OAuth2UserCode;
|
||||
import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames;
|
||||
import org.springframework.security.oauth2.core.oidc.OidcIdToken;
|
||||
import org.springframework.security.oauth2.core.oidc.endpoint.OidcParameterNames;
|
||||
@@ -60,6 +62,10 @@ public class InMemoryOAuth2AuthorizationServiceTests {
|
||||
|
||||
private static final OAuth2TokenType ID_TOKEN_TOKEN_TYPE = new OAuth2TokenType(OidcParameterNames.ID_TOKEN);
|
||||
|
||||
private static final OAuth2TokenType DEVICE_CODE_TOKEN_TYPE = new OAuth2TokenType(OAuth2ParameterNames.DEVICE_CODE);
|
||||
|
||||
private static final OAuth2TokenType USER_CODE_TOKEN_TYPE = new OAuth2TokenType(OAuth2ParameterNames.USER_CODE);
|
||||
|
||||
private InMemoryOAuth2AuthorizationService authorizationService;
|
||||
|
||||
@BeforeEach
|
||||
@@ -313,6 +319,44 @@ public class InMemoryOAuth2AuthorizationServiceTests {
|
||||
assertThat(authorization).isEqualTo(result);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void findByTokenWhenDeviceCodeExistsThenFound() {
|
||||
OAuth2DeviceCode deviceCode = new OAuth2DeviceCode("device-code", Instant.now(),
|
||||
Instant.now().plus(5, ChronoUnit.MINUTES));
|
||||
OAuth2Authorization authorization = OAuth2Authorization.withRegisteredClient(REGISTERED_CLIENT)
|
||||
.id(ID)
|
||||
.principalName(PRINCIPAL_NAME)
|
||||
.authorizationGrantType(AUTHORIZATION_GRANT_TYPE)
|
||||
.token(deviceCode)
|
||||
.build();
|
||||
this.authorizationService.save(authorization);
|
||||
|
||||
OAuth2Authorization result = this.authorizationService.findByToken(deviceCode.getTokenValue(),
|
||||
DEVICE_CODE_TOKEN_TYPE);
|
||||
assertThat(authorization).isEqualTo(result);
|
||||
result = this.authorizationService.findByToken(deviceCode.getTokenValue(), null);
|
||||
assertThat(authorization).isEqualTo(result);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void findByTokenWhenUserCodeExistsThenFound() {
|
||||
OAuth2UserCode userCode = new OAuth2UserCode("user-code", Instant.now(),
|
||||
Instant.now().plus(5, ChronoUnit.MINUTES));
|
||||
OAuth2Authorization authorization = OAuth2Authorization.withRegisteredClient(REGISTERED_CLIENT)
|
||||
.id(ID)
|
||||
.principalName(PRINCIPAL_NAME)
|
||||
.authorizationGrantType(AUTHORIZATION_GRANT_TYPE)
|
||||
.token(userCode)
|
||||
.build();
|
||||
this.authorizationService.save(authorization);
|
||||
|
||||
OAuth2Authorization result = this.authorizationService.findByToken(userCode.getTokenValue(),
|
||||
USER_CODE_TOKEN_TYPE);
|
||||
assertThat(authorization).isEqualTo(result);
|
||||
result = this.authorizationService.findByToken(userCode.getTokenValue(), null);
|
||||
assertThat(authorization).isEqualTo(result);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void findByTokenWhenWrongTokenTypeThenNotFound() {
|
||||
OAuth2RefreshToken refreshToken = new OAuth2RefreshToken("refresh-token", Instant.now());
|
||||
|
||||
+34
@@ -64,6 +64,7 @@ import org.springframework.security.web.authentication.AuthenticationSuccessHand
|
||||
import org.springframework.security.web.authentication.WebAuthenticationDetails;
|
||||
import org.springframework.security.web.authentication.session.SessionAuthenticationStrategy;
|
||||
import org.springframework.util.StringUtils;
|
||||
import org.springframework.web.util.HtmlUtils;
|
||||
import org.springframework.web.util.UriComponentsBuilder;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
@@ -581,6 +582,39 @@ public class OAuth2AuthorizationEndpointFilterTests {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void doFilterWhenAuthorizationRequestConsentRequiredThenConsentResponseHtmlEscaped() throws Exception {
|
||||
String unencodedScope = "<scope2>";
|
||||
String unencodedState = "<state>";
|
||||
Set<String> requestedScopes = new HashSet<>(Arrays.asList("scope1", unencodedScope));
|
||||
RegisteredClient registeredClient = TestRegisteredClients.registeredClient().scopes((scopes) -> {
|
||||
scopes.clear();
|
||||
scopes.addAll(requestedScopes);
|
||||
}).build();
|
||||
// No scopes previously approved
|
||||
OAuth2AuthorizationConsentAuthenticationToken authorizationConsentAuthenticationResult = new OAuth2AuthorizationConsentAuthenticationToken(
|
||||
AUTHORIZATION_URI, registeredClient.getClientId(), this.principal, unencodedState, new HashSet<>(),
|
||||
null);
|
||||
authorizationConsentAuthenticationResult.setAuthenticated(true);
|
||||
given(this.authenticationManager.authenticate(any())).willReturn(authorizationConsentAuthenticationResult);
|
||||
|
||||
MockHttpServletRequest request = createAuthorizationRequest(registeredClient);
|
||||
MockHttpServletResponse response = new MockHttpServletResponse();
|
||||
FilterChain filterChain = mock(FilterChain.class);
|
||||
|
||||
this.filter.doFilter(request, response, filterChain);
|
||||
|
||||
verify(this.authenticationManager).authenticate(any());
|
||||
verifyNoInteractions(filterChain);
|
||||
|
||||
assertThat(response.getStatus()).isEqualTo(HttpStatus.OK.value());
|
||||
String html = response.getContentAsString();
|
||||
assertThat(html).doesNotContain(unencodedScope);
|
||||
assertThat(html).doesNotContain(unencodedState);
|
||||
assertThat(html).contains(HtmlUtils.htmlEscape(unencodedScope));
|
||||
assertThat(html).contains("name=\"state\" value=\"" + HtmlUtils.htmlEscape(unencodedState) + "\"");
|
||||
}
|
||||
|
||||
@Test
|
||||
public void doFilterWhenAuthorizationRequestAuthenticatedThenAuthorizationResponse() throws Exception {
|
||||
RegisteredClient registeredClient = TestRegisteredClients.registeredClient().redirectUris((redirectUris) -> {
|
||||
|
||||
+14
@@ -33,6 +33,8 @@ public final class DelegatingOAuth2TokenValidator<T extends OAuth2Token> impleme
|
||||
|
||||
private final Collection<OAuth2TokenValidator<T>> tokenValidators;
|
||||
|
||||
private boolean failOnError;
|
||||
|
||||
/**
|
||||
* Constructs a {@code DelegatingOAuth2TokenValidator} using the provided validators.
|
||||
* @param tokenValidators the {@link Collection} of {@link OAuth2TokenValidator}s to
|
||||
@@ -57,8 +59,20 @@ public final class DelegatingOAuth2TokenValidator<T extends OAuth2Token> impleme
|
||||
Collection<OAuth2Error> errors = new ArrayList<>();
|
||||
for (OAuth2TokenValidator<T> validator : this.tokenValidators) {
|
||||
errors.addAll(validator.validate(token).getErrors());
|
||||
if (!errors.isEmpty() && this.failOnError) {
|
||||
return OAuth2TokenValidatorResult.failure(errors);
|
||||
}
|
||||
}
|
||||
return OAuth2TokenValidatorResult.failure(errors);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fail-fast when a delegate errors, defaults to {@code false}.
|
||||
* @param failOnError fail-fast when a delegate errors
|
||||
* @since 6.5.12
|
||||
*/
|
||||
public void setFailOnError(boolean failOnError) {
|
||||
this.failOnError = failOnError;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+143
-35
@@ -18,11 +18,11 @@ package org.springframework.security.oauth2.jwt;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.time.Instant;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.time.Duration;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Base64;
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.function.Function;
|
||||
|
||||
@@ -39,12 +39,15 @@ import com.nimbusds.jose.proc.SecurityContext;
|
||||
import com.nimbusds.jwt.proc.ConfigurableJWTProcessor;
|
||||
import com.nimbusds.jwt.proc.DefaultJWTProcessor;
|
||||
|
||||
import org.springframework.security.oauth2.core.ClaimAccessor;
|
||||
import org.springframework.security.oauth2.core.DelegatingOAuth2TokenValidator;
|
||||
import org.springframework.security.oauth2.core.OAuth2Error;
|
||||
import org.springframework.security.oauth2.core.OAuth2ErrorCodes;
|
||||
import org.springframework.security.oauth2.core.OAuth2Token;
|
||||
import org.springframework.security.oauth2.core.OAuth2TokenValidator;
|
||||
import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult;
|
||||
import org.springframework.util.Assert;
|
||||
import org.springframework.util.CollectionUtils;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
/**
|
||||
@@ -63,10 +66,11 @@ public final class DPoPProofJwtDecoderFactory implements JwtDecoderFactory<DPoPP
|
||||
|
||||
/**
|
||||
* The default {@code OAuth2TokenValidator<Jwt>} factory that validates the
|
||||
* {@code htm}, {@code htu}, {@code jti} and {@code iat} claims of the DPoP Proof
|
||||
* {@link Jwt}.
|
||||
* {@code htm}, {@code htu}, {@code iat}, {@code jkt}, {@code ath} and {@code jti}
|
||||
* claims of the DPoP Proof {@link Jwt}.
|
||||
*/
|
||||
public static final Function<DPoPProofContext, OAuth2TokenValidator<Jwt>> DEFAULT_JWT_VALIDATOR_FACTORY = defaultJwtValidatorFactory();
|
||||
public static final Function<DPoPProofContext, OAuth2TokenValidator<Jwt>> DEFAULT_JWT_VALIDATOR_FACTORY = createDefaultJwtValidatorFactory(
|
||||
Collections.emptyList());
|
||||
|
||||
private static final JOSEObjectTypeVerifier<SecurityContext> DPOP_TYPE_VERIFIER = new DefaultJOSEObjectTypeVerifier<>(
|
||||
new JOSEObjectType("dpop+jwt"));
|
||||
@@ -94,6 +98,70 @@ public final class DPoPProofJwtDecoderFactory implements JwtDecoderFactory<DPoPP
|
||||
this.jwtValidatorFactory = jwtValidatorFactory;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a factory that provides an {@link OAuth2TokenValidator} for the specified
|
||||
* {@link DPoPProofContext} and is used by the {@link JwtDecoder}. The returned
|
||||
* factory provides a validator that validates the {@code htm}, {@code htu},
|
||||
* {@code iat}, {@code jkt}, {@code ath} and {@code jti} claims, along with any custom
|
||||
* validators provided.
|
||||
* @param validators the custom validators to add
|
||||
* @return a factory that provides an {@link OAuth2TokenValidator} for the specified
|
||||
* {@link DPoPProofContext}
|
||||
* @since 6.5.12
|
||||
*/
|
||||
public static Function<DPoPProofContext, OAuth2TokenValidator<Jwt>> createDefaultJwtValidatorFactory(
|
||||
List<OAuth2TokenValidator<Jwt>> validators) {
|
||||
Assert.notNull(validators, "validators cannot be null");
|
||||
List<OAuth2TokenValidator<Jwt>> customValidators = new ArrayList<>();
|
||||
if (!CollectionUtils.isEmpty(validators)) {
|
||||
customValidators.addAll(validators);
|
||||
}
|
||||
final Duration clockSkew = Duration.ofSeconds(30);
|
||||
final JwtIssuedAtValidator jwtIssuedAtValidator;
|
||||
if (CollectionUtils.findValueOfType(customValidators, JwtIssuedAtValidator.class) != null) {
|
||||
jwtIssuedAtValidator = CollectionUtils.findValueOfType(customValidators, JwtIssuedAtValidator.class);
|
||||
customValidators.remove(jwtIssuedAtValidator);
|
||||
}
|
||||
else {
|
||||
jwtIssuedAtValidator = new JwtIssuedAtValidator(true);
|
||||
jwtIssuedAtValidator.setClockSkew(clockSkew);
|
||||
}
|
||||
final DPoPProofReplayValidator dPoPProofReplayValidator;
|
||||
if (CollectionUtils.findValueOfType(customValidators, DPoPProofReplayValidator.class) != null) {
|
||||
dPoPProofReplayValidator = CollectionUtils.findValueOfType(customValidators,
|
||||
DPoPProofReplayValidator.class);
|
||||
customValidators.remove(dPoPProofReplayValidator);
|
||||
}
|
||||
else {
|
||||
dPoPProofReplayValidator = new DPoPProofReplayValidator(new DPoPProofReplayValidator.InMemoryCache());
|
||||
dPoPProofReplayValidator.setClockSkew(clockSkew);
|
||||
}
|
||||
return (context) -> createDefaultJwtValidatorFactory(context, jwtIssuedAtValidator, dPoPProofReplayValidator,
|
||||
customValidators);
|
||||
}
|
||||
|
||||
private static OAuth2TokenValidator<Jwt> createDefaultJwtValidatorFactory(DPoPProofContext context,
|
||||
JwtIssuedAtValidator jwtIssuedAtValidator, DPoPProofReplayValidator dPoPProofReplayValidator,
|
||||
List<OAuth2TokenValidator<Jwt>> customValidators) {
|
||||
// Add custom validators first then default validators in a specific order
|
||||
List<OAuth2TokenValidator<Jwt>> tokenValidators = new ArrayList<>();
|
||||
if (!CollectionUtils.isEmpty(customValidators)) {
|
||||
tokenValidators.addAll(customValidators);
|
||||
}
|
||||
tokenValidators.add(new JwtClaimValidator<>("htm", context.getMethod()::equalsIgnoreCase));
|
||||
tokenValidators.add(new JwtClaimValidator<>("htu", context.getTargetUri()::equals));
|
||||
tokenValidators.add(jwtIssuedAtValidator);
|
||||
if (context.getAccessToken() != null) {
|
||||
tokenValidators.add(new JwkThumbprintValidator(context.getAccessToken()));
|
||||
tokenValidators.add(new AthClaimValidator(context.getAccessToken()));
|
||||
}
|
||||
tokenValidators.add(dPoPProofReplayValidator);
|
||||
DelegatingOAuth2TokenValidator<Jwt> delegatingTokenValidator = new DelegatingOAuth2TokenValidator<>(
|
||||
tokenValidators);
|
||||
delegatingTokenValidator.setFailOnError(true);
|
||||
return delegatingTokenValidator;
|
||||
}
|
||||
|
||||
private static NimbusJwtDecoder buildDecoder() {
|
||||
ConfigurableJWTProcessor<SecurityContext> jwtProcessor = new DefaultJWTProcessor<>();
|
||||
jwtProcessor.setJWSTypeVerifier(DPOP_TYPE_VERIFIER);
|
||||
@@ -137,39 +205,34 @@ public final class DPoPProofJwtDecoderFactory implements JwtDecoderFactory<DPoPP
|
||||
};
|
||||
}
|
||||
|
||||
private static Function<DPoPProofContext, OAuth2TokenValidator<Jwt>> defaultJwtValidatorFactory() {
|
||||
return (context) -> new DelegatingOAuth2TokenValidator<>(
|
||||
new JwtClaimValidator<>("htm", context.getMethod()::equals),
|
||||
new JwtClaimValidator<>("htu", context.getTargetUri()::equals), new JtiClaimValidator(),
|
||||
new JwtIssuedAtValidator(true));
|
||||
}
|
||||
private static final class AthClaimValidator implements OAuth2TokenValidator<Jwt> {
|
||||
|
||||
private static final class JtiClaimValidator implements OAuth2TokenValidator<Jwt> {
|
||||
private final OAuth2Token accessToken;
|
||||
|
||||
private static final Map<String, Long> JTI_CACHE = Collections.synchronizedMap(new JtiCache());
|
||||
private AthClaimValidator(OAuth2Token accessToken) {
|
||||
Assert.notNull(accessToken, "accessToken cannot be null");
|
||||
this.accessToken = accessToken;
|
||||
}
|
||||
|
||||
@Override
|
||||
public OAuth2TokenValidatorResult validate(Jwt jwt) {
|
||||
Assert.notNull(jwt, "DPoP proof jwt cannot be null");
|
||||
String jti = jwt.getId();
|
||||
if (!StringUtils.hasText(jti)) {
|
||||
OAuth2Error error = createOAuth2Error("jti claim is required.");
|
||||
String accessTokenHashClaim = jwt.getClaimAsString("ath");
|
||||
if (!StringUtils.hasText(accessTokenHashClaim)) {
|
||||
OAuth2Error error = createOAuth2Error("ath claim is required.");
|
||||
return OAuth2TokenValidatorResult.failure(error);
|
||||
}
|
||||
|
||||
// Enforce single-use to protect against DPoP proof replay
|
||||
String jtiHash;
|
||||
String accessTokenHash;
|
||||
try {
|
||||
jtiHash = computeSHA256(jti);
|
||||
accessTokenHash = computeSHA256(this.accessToken.getTokenValue());
|
||||
}
|
||||
catch (Exception ex) {
|
||||
OAuth2Error error = createOAuth2Error("jti claim is invalid.");
|
||||
OAuth2Error error = createOAuth2Error("Failed to compute SHA-256 Thumbprint for access token.");
|
||||
return OAuth2TokenValidatorResult.failure(error);
|
||||
}
|
||||
Instant expiry = Instant.now().plus(1, ChronoUnit.HOURS);
|
||||
if ((JTI_CACHE.putIfAbsent(jtiHash, expiry.toEpochMilli())) != null) {
|
||||
// Already used
|
||||
OAuth2Error error = createOAuth2Error("jti claim is invalid.");
|
||||
if (!accessTokenHashClaim.equals(accessTokenHash)) {
|
||||
OAuth2Error error = createOAuth2Error("ath claim is invalid.");
|
||||
return OAuth2TokenValidatorResult.failure(error);
|
||||
}
|
||||
return OAuth2TokenValidatorResult.success();
|
||||
@@ -185,20 +248,65 @@ public final class DPoPProofJwtDecoderFactory implements JwtDecoderFactory<DPoPP
|
||||
return Base64.getUrlEncoder().withoutPadding().encodeToString(digest);
|
||||
}
|
||||
|
||||
@SuppressWarnings("serial")
|
||||
private static final class JtiCache extends LinkedHashMap<String, Long> {
|
||||
}
|
||||
|
||||
private static final int MAX_SIZE = 1000;
|
||||
private static final class JwkThumbprintValidator implements OAuth2TokenValidator<Jwt> {
|
||||
|
||||
@Override
|
||||
protected boolean removeEldestEntry(Map.Entry<String, Long> eldest) {
|
||||
if (size() > MAX_SIZE) {
|
||||
return true;
|
||||
}
|
||||
Instant expiry = Instant.ofEpochMilli(eldest.getValue());
|
||||
return Instant.now().isAfter(expiry);
|
||||
private final OAuth2Token accessToken;
|
||||
|
||||
private final ClaimAccessor claims;
|
||||
|
||||
private JwkThumbprintValidator(OAuth2Token accessToken) {
|
||||
Assert.notNull(accessToken, "accessToken cannot be null");
|
||||
Assert.isInstanceOf(ClaimAccessor.class, accessToken, "accessToken must be instance of ClaimAccessor");
|
||||
this.accessToken = accessToken;
|
||||
this.claims = (ClaimAccessor) accessToken;
|
||||
}
|
||||
|
||||
@Override
|
||||
public OAuth2TokenValidatorResult validate(Jwt jwt) {
|
||||
Assert.notNull(jwt, "DPoP proof jwt cannot be null");
|
||||
String jwkThumbprintClaim = null;
|
||||
Map<String, Object> confirmationMethodClaim = this.claims.getClaimAsMap("cnf");
|
||||
if (!CollectionUtils.isEmpty(confirmationMethodClaim) && confirmationMethodClaim.containsKey("jkt")) {
|
||||
jwkThumbprintClaim = (String) confirmationMethodClaim.get("jkt");
|
||||
}
|
||||
if (jwkThumbprintClaim == null) {
|
||||
OAuth2Error error = createOAuth2Error("jkt claim is required.");
|
||||
return OAuth2TokenValidatorResult.failure(error);
|
||||
}
|
||||
|
||||
JWK jwk = null;
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, Object> jwkJson = (Map<String, Object>) jwt.getHeaders().get("jwk");
|
||||
try {
|
||||
jwk = JWK.parse(jwkJson);
|
||||
}
|
||||
catch (Exception ignored) {
|
||||
}
|
||||
if (jwk == null) {
|
||||
OAuth2Error error = createOAuth2Error("jwk header is missing or invalid.");
|
||||
return OAuth2TokenValidatorResult.failure(error);
|
||||
}
|
||||
|
||||
String jwkThumbprint;
|
||||
try {
|
||||
jwkThumbprint = jwk.computeThumbprint().toString();
|
||||
}
|
||||
catch (Exception ex) {
|
||||
OAuth2Error error = createOAuth2Error("Failed to compute SHA-256 Thumbprint for jwk.");
|
||||
return OAuth2TokenValidatorResult.failure(error);
|
||||
}
|
||||
|
||||
if (!jwkThumbprintClaim.equals(jwkThumbprint)) {
|
||||
OAuth2Error error = createOAuth2Error("jkt claim is invalid.");
|
||||
return OAuth2TokenValidatorResult.failure(error);
|
||||
}
|
||||
return OAuth2TokenValidatorResult.success();
|
||||
}
|
||||
|
||||
private static OAuth2Error createOAuth2Error(String reason) {
|
||||
return new OAuth2Error(OAuth2ErrorCodes.INVALID_DPOP_PROOF, reason, null);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+446
@@ -0,0 +1,446 @@
|
||||
/*
|
||||
* 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.oauth2.jwt;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.Base64;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.Callable;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.ConcurrentMap;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
import com.nimbusds.jose.jwk.JWK;
|
||||
|
||||
import org.springframework.cache.Cache;
|
||||
import org.springframework.cache.support.SimpleValueWrapper;
|
||||
import org.springframework.lang.Nullable;
|
||||
import org.springframework.security.oauth2.core.OAuth2Error;
|
||||
import org.springframework.security.oauth2.core.OAuth2ErrorCodes;
|
||||
import org.springframework.security.oauth2.core.OAuth2TokenValidator;
|
||||
import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult;
|
||||
import org.springframework.util.Assert;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
/**
|
||||
* An {@link OAuth2TokenValidator} that mitigates DPoP Proof Replay.
|
||||
*
|
||||
* <p>
|
||||
* This validator mitigates DPoP Proof Replay by ensuring the DPoP Proof:
|
||||
* <ul>
|
||||
* <li>contains the {@code iat} (issued at) claim, and it's within an acceptable time
|
||||
* window (configured via {@link #setClockSkew(Duration)})</li>
|
||||
* <li>contains the {@code jti} (JWT ID) claim, and it has not been used previously</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>
|
||||
* This implementation uses a {@link Cache} to store the {@code jti} claim (along with
|
||||
* other information in {@link CacheValue CacheValue}) to enforce single-use. The
|
||||
* {@code jti} is retained in the cache until the DPoP Proof expires, which is calculated
|
||||
* as {@code iat + clockSkew}.
|
||||
*
|
||||
* @author Joe Grandja
|
||||
* @since 6.5.12
|
||||
* @see OAuth2TokenValidator
|
||||
* @see DPoPProofJwtDecoderFactory
|
||||
* @see <a target="_blank" href=
|
||||
* "https://datatracker.ietf.org/doc/html/rfc9449#section-11.1">Section 11.1. DPoP Proof
|
||||
* Replay</a>
|
||||
*/
|
||||
public final class DPoPProofReplayValidator implements OAuth2TokenValidator<Jwt> {
|
||||
|
||||
private final Cache cache;
|
||||
|
||||
private Duration clockSkew = Duration.ofSeconds(30);
|
||||
|
||||
private Clock clock = Clock.systemUTC();
|
||||
|
||||
/**
|
||||
* Constructs a {@code DPoPProofReplayValidator} using the provided parameters.
|
||||
* @param cache the {@link Cache} used to store {@link CacheValue} which contains
|
||||
* information of the used DPoP Proof {@link Jwt}'s
|
||||
*/
|
||||
public DPoPProofReplayValidator(Cache cache) {
|
||||
Assert.notNull(cache, "cache cannot be null");
|
||||
this.cache = cache;
|
||||
}
|
||||
|
||||
@Override
|
||||
public OAuth2TokenValidatorResult validate(Jwt jwt) {
|
||||
Assert.notNull(jwt, "DPoP proof jwt cannot be null");
|
||||
String jti = jwt.getId();
|
||||
if (!StringUtils.hasText(jti)) {
|
||||
OAuth2Error error = createOAuth2Error("jti claim is required.");
|
||||
return OAuth2TokenValidatorResult.failure(error);
|
||||
}
|
||||
|
||||
Instant issuedAt = jwt.getIssuedAt();
|
||||
if (issuedAt == null) {
|
||||
OAuth2Error error = createOAuth2Error("iat claim is required.");
|
||||
return OAuth2TokenValidatorResult.failure(error);
|
||||
}
|
||||
|
||||
// Ensure acceptable time window
|
||||
Instant now = Instant.now(this.clock);
|
||||
Instant notBefore = now.minus(this.clockSkew);
|
||||
Instant notAfter = now.plus(this.clockSkew);
|
||||
if (issuedAt.isBefore(notBefore) || issuedAt.isAfter(notAfter)) {
|
||||
OAuth2Error error = createOAuth2Error("iat claim is invalid.");
|
||||
return OAuth2TokenValidatorResult.failure(error);
|
||||
}
|
||||
|
||||
String jwkThumbprint;
|
||||
try {
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, Object> jwkJson = (Map<String, Object>) jwt.getHeaders().get("jwk");
|
||||
JWK jwk = JWK.parse(jwkJson);
|
||||
jwkThumbprint = jwk.computeThumbprint().toString();
|
||||
}
|
||||
catch (Exception ex) {
|
||||
OAuth2Error error = createOAuth2Error("jwk header is missing or invalid.");
|
||||
return OAuth2TokenValidatorResult.failure(error);
|
||||
}
|
||||
|
||||
String jtiHash;
|
||||
try {
|
||||
jtiHash = computeSHA256(jti);
|
||||
}
|
||||
catch (Exception ex) {
|
||||
OAuth2Error error = createOAuth2Error("jti claim is invalid.");
|
||||
return OAuth2TokenValidatorResult.failure(error);
|
||||
}
|
||||
|
||||
Instant expiresAt = issuedAt.plus(this.clockSkew);
|
||||
CacheValue cacheValue = new CacheValue(issuedAt, expiresAt, jwkThumbprint);
|
||||
|
||||
// Enforce single-use to protect against DPoP proof replay
|
||||
if (this.cache.putIfAbsent(jtiHash, cacheValue) != null) {
|
||||
// Already used or cache full or key limit reached
|
||||
OAuth2Error error = createOAuth2Error("jti claim is invalid or unable to cache.");
|
||||
return OAuth2TokenValidatorResult.failure(error);
|
||||
}
|
||||
return OAuth2TokenValidatorResult.success();
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the clock skew. The default is 30 seconds.
|
||||
* @param clockSkew the clock skew
|
||||
*/
|
||||
public void setClockSkew(Duration clockSkew) {
|
||||
Assert.notNull(clockSkew, "clockSkew cannot be null");
|
||||
Assert.isTrue(clockSkew.getSeconds() >= 0, "clockSkew must be >= 0");
|
||||
this.clockSkew = clockSkew;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the {@link Clock} used in {@link Instant#now(Clock)}.
|
||||
* @param clock the clock
|
||||
*/
|
||||
public void setClock(Clock clock) {
|
||||
Assert.notNull(clock, "clock cannot be null");
|
||||
this.clock = clock;
|
||||
}
|
||||
|
||||
private static OAuth2Error createOAuth2Error(String reason) {
|
||||
return new OAuth2Error(OAuth2ErrorCodes.INVALID_DPOP_PROOF, reason, null);
|
||||
}
|
||||
|
||||
private static String computeSHA256(String value) throws Exception {
|
||||
MessageDigest md = MessageDigest.getInstance("SHA-256");
|
||||
byte[] digest = md.digest(value.getBytes(StandardCharsets.UTF_8));
|
||||
return Base64.getUrlEncoder().withoutPadding().encodeToString(digest);
|
||||
}
|
||||
|
||||
/**
|
||||
* An in-memory {@link Cache} implementation backed by a {@link ConcurrentHashMap}.
|
||||
*
|
||||
* <p>
|
||||
* <b>NOTE:</b> This implementation has limitations as it only works in a single-node
|
||||
* setup. For production (and clustered) environments, it is recommended to use a
|
||||
* distributed {@link Cache} implementation (e.g. Redis, Hazelcast, etc.).
|
||||
*
|
||||
* <p>
|
||||
* This implementation can be fine-tuned based on the following configuration
|
||||
* settings:
|
||||
* <ul>
|
||||
* <li>{@link #setMaxSize(int)} - Sets the maximum number of entries the cache can
|
||||
* hold. The default is 100,000.</li>
|
||||
* <li>{@link #setMaxRequestsPerKey(int)} - Sets the maximum number of requests
|
||||
* allowed per {@link CacheValue#getJwkThumbprint() JWK thumbprint}. The default is
|
||||
* 1000.</li>
|
||||
* </ul>
|
||||
*/
|
||||
public static final class InMemoryCache implements Cache {
|
||||
|
||||
private static final String DEFAULT_NAME = InMemoryCache.class.getName().concat(".DPOP-PROOF-CACHE");
|
||||
|
||||
private static final int DEFAULT_MAX_SIZE = 100_000;
|
||||
|
||||
private static final int DEFAULT_MAX_REQUESTS_PER_KEY = 1000;
|
||||
|
||||
private static final int CLEANUP_INTERVAL_SECS = 10;
|
||||
|
||||
private final ConcurrentMap<String, CacheValue> cache = new ConcurrentHashMap<>();
|
||||
|
||||
private final ConcurrentMap<String, Integer> requestsPerKey = new ConcurrentHashMap<>();
|
||||
|
||||
private final AtomicBoolean cleaning = new AtomicBoolean(false);
|
||||
|
||||
private long lastCleanup = System.currentTimeMillis();
|
||||
|
||||
private int maxSize = DEFAULT_MAX_SIZE;
|
||||
|
||||
private int maxRequestsPerKey = DEFAULT_MAX_REQUESTS_PER_KEY;
|
||||
|
||||
/**
|
||||
* Returns the maximum number of entries the cache can hold.
|
||||
* @return the maximum number of entries the cache can hold
|
||||
*/
|
||||
public int getMaxSize() {
|
||||
return this.maxSize;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the maximum number of entries the cache can hold. The default is 100,000.
|
||||
* @param maxSize the maximum number of entries the cache can hold
|
||||
*/
|
||||
public void setMaxSize(int maxSize) {
|
||||
Assert.isTrue(maxSize > 0, "maxSize must be > 0");
|
||||
this.maxSize = maxSize;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the maximum number of requests allowed per
|
||||
* {@link CacheValue#getJwkThumbprint() JWK thumbprint}.
|
||||
* @return the maximum number of requests allowed per
|
||||
* {@link CacheValue#getJwkThumbprint() JWK thumbprint}
|
||||
*/
|
||||
public int getMaxRequestsPerKey() {
|
||||
return this.maxRequestsPerKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the maximum number of requests allowed per
|
||||
* {@link CacheValue#getJwkThumbprint() JWK thumbprint}. The default is 1000.
|
||||
* @param maxRequestsPerKey the maximum number of requests allowed per
|
||||
* {@link CacheValue#getJwkThumbprint() JWK thumbprint}
|
||||
*/
|
||||
public void setMaxRequestsPerKey(int maxRequestsPerKey) {
|
||||
Assert.isTrue(maxRequestsPerKey > 0, "maxRequestsPerKey must be > 0");
|
||||
this.maxRequestsPerKey = maxRequestsPerKey;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getName() {
|
||||
return DEFAULT_NAME;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Object getNativeCache() {
|
||||
return this.cache;
|
||||
}
|
||||
|
||||
@Override
|
||||
public @Nullable ValueWrapper get(Object key) {
|
||||
Object value = this.cache.get(key);
|
||||
return (value != null) ? new SimpleValueWrapper(value) : null;
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
@Override
|
||||
public <T> @Nullable T get(Object key, @Nullable Class<T> type) {
|
||||
Object value = this.cache.get(key);
|
||||
if (value != null && type != null && !type.isInstance(value)) {
|
||||
throw new IllegalStateException(
|
||||
"Cached value is not of required type [" + type.getName() + "]: " + value);
|
||||
}
|
||||
return (T) value;
|
||||
}
|
||||
|
||||
@Override
|
||||
public <T> @Nullable T get(Object key, Callable<T> valueLoader) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void put(Object key, Object value) {
|
||||
putIfAbsent(key, value);
|
||||
}
|
||||
|
||||
@Override
|
||||
public @Nullable ValueWrapper putIfAbsent(Object key, Object value) {
|
||||
String jti = (String) key;
|
||||
CacheValue cacheValue = (CacheValue) value;
|
||||
|
||||
cleanupIfNecessary();
|
||||
if (this.cache.size() >= this.maxSize) {
|
||||
// Force an immediate cleanup when we hit the limit before the cleanup
|
||||
// interval
|
||||
cleanup();
|
||||
if (this.cache.size() >= this.maxSize) {
|
||||
// Cache full - return non-null value
|
||||
return new SimpleValueWrapper(cacheValue);
|
||||
}
|
||||
}
|
||||
|
||||
// Limit the number of requests per key
|
||||
AtomicBoolean limitExceeded = new AtomicBoolean(false);
|
||||
this.requestsPerKey.compute(cacheValue.jwkThumbprint, (k, v) -> {
|
||||
if (v != null && v >= this.maxRequestsPerKey) {
|
||||
limitExceeded.set(true);
|
||||
return v;
|
||||
}
|
||||
// Increment
|
||||
return (v != null) ? v + 1 : 1;
|
||||
});
|
||||
if (limitExceeded.get()) {
|
||||
// Key limit reached - return non-null value
|
||||
return new SimpleValueWrapper(cacheValue);
|
||||
}
|
||||
|
||||
if (this.cache.putIfAbsent(jti, cacheValue) != null) {
|
||||
// jti exists - revert the increment and return non-null value
|
||||
this.requestsPerKey.computeIfPresent(cacheValue.jwkThumbprint, (k, v) -> (v > 1) ? v - 1 : null);
|
||||
return new SimpleValueWrapper(cacheValue);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void evict(Object key) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void clear() {
|
||||
}
|
||||
|
||||
private void cleanupIfNecessary() {
|
||||
long now = System.currentTimeMillis();
|
||||
long last = this.lastCleanup;
|
||||
if ((now - last) > (CLEANUP_INTERVAL_SECS * 1000)) {
|
||||
cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
private void cleanup() {
|
||||
if (this.cleaning.compareAndSet(false, true)) {
|
||||
try {
|
||||
Instant now = Instant.now();
|
||||
for (Map.Entry<String, CacheValue> entry : this.cache.entrySet()) {
|
||||
if (now.isAfter(entry.getValue().expiresAt)) {
|
||||
this.cache.remove(entry.getKey());
|
||||
this.requestsPerKey.computeIfPresent(entry.getValue().jwkThumbprint,
|
||||
(k, v) -> (v > 1) ? v - 1 : null);
|
||||
}
|
||||
}
|
||||
this.lastCleanup = System.currentTimeMillis();
|
||||
}
|
||||
finally {
|
||||
this.cleaning.set(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* A representation of the value to which the {@link Cache} maps a (hashed)
|
||||
* {@code (jti)} claim as the key.
|
||||
*/
|
||||
public static final class CacheValue {
|
||||
|
||||
private final Instant issuedAt;
|
||||
|
||||
private final Instant expiresAt;
|
||||
|
||||
private final String jwkThumbprint;
|
||||
|
||||
/**
|
||||
* Constructs a {@code CacheValue} using the provided parameters.
|
||||
* @param issuedAt the issued at claim which identifies the time at which the DPoP
|
||||
* Proof {@link Jwt} was issued
|
||||
* @param expiresAt the expiration time when this {@code CacheValue} will be
|
||||
* evicted from the cache
|
||||
* @param jwkThumbprint the SHA-256 thumbprint of the public key of the JSON Web
|
||||
* Key (JWK) corresponding to the key used to digitally sign the DPoP Proof
|
||||
* {@link Jwt}
|
||||
*/
|
||||
public CacheValue(Instant issuedAt, Instant expiresAt, String jwkThumbprint) {
|
||||
Assert.notNull(issuedAt, "issuedAt cannot be null");
|
||||
Assert.notNull(expiresAt, "expiresAt cannot be null");
|
||||
Assert.hasText(jwkThumbprint, "jwkThumbprint cannot be empty");
|
||||
this.issuedAt = issuedAt;
|
||||
this.expiresAt = expiresAt;
|
||||
this.jwkThumbprint = jwkThumbprint;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the issued at {@code (iat)} claim which identifies the time at which
|
||||
* the DPoP Proof {@link Jwt} was issued.
|
||||
* @return the issued at claim which identifies the time at which the DPoP Proof
|
||||
* {@link Jwt} was issued
|
||||
*/
|
||||
public Instant getIssuedAt() {
|
||||
return this.issuedAt;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the expiration time when this {@code CacheValue} will be evicted from
|
||||
* the cache.
|
||||
* @return the expiration time when this {@code CacheValue} will be evicted from
|
||||
* the cache
|
||||
*/
|
||||
public Instant getExpiresAt() {
|
||||
return this.expiresAt;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the SHA-256 thumbprint of the public key of the JSON Web Key (JWK)
|
||||
* corresponding to the key used to digitally sign the DPoP Proof {@link Jwt}.
|
||||
* @return the SHA-256 thumbprint of the public key of the JSON Web Key (JWK)
|
||||
*/
|
||||
public String getJwkThumbprint() {
|
||||
return this.jwkThumbprint;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object obj) {
|
||||
if (obj == this) {
|
||||
return true;
|
||||
}
|
||||
if (obj == null || obj.getClass() != this.getClass()) {
|
||||
return false;
|
||||
}
|
||||
CacheValue that = (CacheValue) obj;
|
||||
return Objects.equals(this.issuedAt, that.issuedAt) && Objects.equals(this.expiresAt, that.expiresAt)
|
||||
&& Objects.equals(this.jwkThumbprint, that.jwkThumbprint);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return Objects.hash(this.issuedAt, this.expiresAt, this.jwkThumbprint);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
+2
-125
@@ -16,36 +16,24 @@
|
||||
|
||||
package org.springframework.security.oauth2.server.resource.authentication;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.time.Instant;
|
||||
import java.util.Base64;
|
||||
import java.util.Map;
|
||||
import java.util.function.Function;
|
||||
|
||||
import com.nimbusds.jose.jwk.JWK;
|
||||
|
||||
import org.springframework.security.authentication.AuthenticationManager;
|
||||
import org.springframework.security.authentication.AuthenticationProvider;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.AuthenticationException;
|
||||
import org.springframework.security.oauth2.core.ClaimAccessor;
|
||||
import org.springframework.security.oauth2.core.DelegatingOAuth2TokenValidator;
|
||||
import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
|
||||
import org.springframework.security.oauth2.core.OAuth2Error;
|
||||
import org.springframework.security.oauth2.core.OAuth2ErrorCodes;
|
||||
import org.springframework.security.oauth2.core.OAuth2Token;
|
||||
import org.springframework.security.oauth2.core.OAuth2TokenValidator;
|
||||
import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult;
|
||||
import org.springframework.security.oauth2.jwt.DPoPProofContext;
|
||||
import org.springframework.security.oauth2.jwt.DPoPProofJwtDecoderFactory;
|
||||
import org.springframework.security.oauth2.jwt.Jwt;
|
||||
import org.springframework.security.oauth2.jwt.JwtDecoder;
|
||||
import org.springframework.security.oauth2.jwt.JwtDecoderFactory;
|
||||
import org.springframework.security.oauth2.jwt.JwtException;
|
||||
import org.springframework.util.Assert;
|
||||
import org.springframework.util.CollectionUtils;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
/**
|
||||
* An {@link AuthenticationProvider} implementation that is responsible for authenticating
|
||||
@@ -72,16 +60,7 @@ public final class DPoPAuthenticationProvider implements AuthenticationProvider
|
||||
public DPoPAuthenticationProvider(AuthenticationManager tokenAuthenticationManager) {
|
||||
Assert.notNull(tokenAuthenticationManager, "tokenAuthenticationManager cannot be null");
|
||||
this.tokenAuthenticationManager = tokenAuthenticationManager;
|
||||
Function<DPoPProofContext, OAuth2TokenValidator<Jwt>> jwtValidatorFactory = (
|
||||
context) -> new DelegatingOAuth2TokenValidator<>(
|
||||
// Use default validators
|
||||
DPoPProofJwtDecoderFactory.DEFAULT_JWT_VALIDATOR_FACTORY.apply(context),
|
||||
// Add custom validators
|
||||
new AthClaimValidator(context.getAccessToken()),
|
||||
new JwkThumbprintValidator(context.getAccessToken()));
|
||||
DPoPProofJwtDecoderFactory dPoPProofJwtDecoderFactory = new DPoPProofJwtDecoderFactory();
|
||||
dPoPProofJwtDecoderFactory.setJwtValidatorFactory(jwtValidatorFactory);
|
||||
this.dPoPProofVerifierFactory = dPoPProofJwtDecoderFactory;
|
||||
this.dPoPProofVerifierFactory = new DPoPProofJwtDecoderFactory();
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -116,7 +95,7 @@ public final class DPoPAuthenticationProvider implements AuthenticationProvider
|
||||
try {
|
||||
dPoPProofVerifier.decode(dPoPProofContext.getDPoPProof());
|
||||
}
|
||||
catch (JwtException ex) {
|
||||
catch (Exception ex) {
|
||||
OAuth2Error error = new OAuth2Error(OAuth2ErrorCodes.INVALID_DPOP_PROOF);
|
||||
throw new OAuth2AuthenticationException(error, ex);
|
||||
}
|
||||
@@ -141,108 +120,6 @@ public final class DPoPAuthenticationProvider implements AuthenticationProvider
|
||||
this.dPoPProofVerifierFactory = dPoPProofVerifierFactory;
|
||||
}
|
||||
|
||||
private static final class AthClaimValidator implements OAuth2TokenValidator<Jwt> {
|
||||
|
||||
private final OAuth2AccessTokenClaims accessToken;
|
||||
|
||||
private AthClaimValidator(OAuth2AccessTokenClaims accessToken) {
|
||||
Assert.notNull(accessToken, "accessToken cannot be null");
|
||||
this.accessToken = accessToken;
|
||||
}
|
||||
|
||||
@Override
|
||||
public OAuth2TokenValidatorResult validate(Jwt jwt) {
|
||||
Assert.notNull(jwt, "DPoP proof jwt cannot be null");
|
||||
String accessTokenHashClaim = jwt.getClaimAsString("ath");
|
||||
if (!StringUtils.hasText(accessTokenHashClaim)) {
|
||||
OAuth2Error error = createOAuth2Error("ath claim is required.");
|
||||
return OAuth2TokenValidatorResult.failure(error);
|
||||
}
|
||||
|
||||
String accessTokenHash;
|
||||
try {
|
||||
accessTokenHash = computeSHA256(this.accessToken.getTokenValue());
|
||||
}
|
||||
catch (Exception ex) {
|
||||
OAuth2Error error = createOAuth2Error("Failed to compute SHA-256 Thumbprint for access token.");
|
||||
return OAuth2TokenValidatorResult.failure(error);
|
||||
}
|
||||
if (!accessTokenHashClaim.equals(accessTokenHash)) {
|
||||
OAuth2Error error = createOAuth2Error("ath claim is invalid.");
|
||||
return OAuth2TokenValidatorResult.failure(error);
|
||||
}
|
||||
return OAuth2TokenValidatorResult.success();
|
||||
}
|
||||
|
||||
private static OAuth2Error createOAuth2Error(String reason) {
|
||||
return new OAuth2Error(OAuth2ErrorCodes.INVALID_DPOP_PROOF, reason, null);
|
||||
}
|
||||
|
||||
private static String computeSHA256(String value) throws Exception {
|
||||
MessageDigest md = MessageDigest.getInstance("SHA-256");
|
||||
byte[] digest = md.digest(value.getBytes(StandardCharsets.UTF_8));
|
||||
return Base64.getUrlEncoder().withoutPadding().encodeToString(digest);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private static final class JwkThumbprintValidator implements OAuth2TokenValidator<Jwt> {
|
||||
|
||||
private final OAuth2AccessTokenClaims accessToken;
|
||||
|
||||
private JwkThumbprintValidator(OAuth2AccessTokenClaims accessToken) {
|
||||
Assert.notNull(accessToken, "accessToken cannot be null");
|
||||
this.accessToken = accessToken;
|
||||
}
|
||||
|
||||
@Override
|
||||
public OAuth2TokenValidatorResult validate(Jwt jwt) {
|
||||
Assert.notNull(jwt, "DPoP proof jwt cannot be null");
|
||||
String jwkThumbprintClaim = null;
|
||||
Map<String, Object> confirmationMethodClaim = this.accessToken.getClaimAsMap("cnf");
|
||||
if (!CollectionUtils.isEmpty(confirmationMethodClaim) && confirmationMethodClaim.containsKey("jkt")) {
|
||||
jwkThumbprintClaim = (String) confirmationMethodClaim.get("jkt");
|
||||
}
|
||||
if (jwkThumbprintClaim == null) {
|
||||
OAuth2Error error = createOAuth2Error("jkt claim is required.");
|
||||
return OAuth2TokenValidatorResult.failure(error);
|
||||
}
|
||||
|
||||
JWK jwk = null;
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, Object> jwkJson = (Map<String, Object>) jwt.getHeaders().get("jwk");
|
||||
try {
|
||||
jwk = JWK.parse(jwkJson);
|
||||
}
|
||||
catch (Exception ignored) {
|
||||
}
|
||||
if (jwk == null) {
|
||||
OAuth2Error error = createOAuth2Error("jwk header is missing or invalid.");
|
||||
return OAuth2TokenValidatorResult.failure(error);
|
||||
}
|
||||
|
||||
String jwkThumbprint;
|
||||
try {
|
||||
jwkThumbprint = jwk.computeThumbprint().toString();
|
||||
}
|
||||
catch (Exception ex) {
|
||||
OAuth2Error error = createOAuth2Error("Failed to compute SHA-256 Thumbprint for jwk.");
|
||||
return OAuth2TokenValidatorResult.failure(error);
|
||||
}
|
||||
|
||||
if (!jwkThumbprintClaim.equals(jwkThumbprint)) {
|
||||
OAuth2Error error = createOAuth2Error("jkt claim is invalid.");
|
||||
return OAuth2TokenValidatorResult.failure(error);
|
||||
}
|
||||
return OAuth2TokenValidatorResult.success();
|
||||
}
|
||||
|
||||
private static OAuth2Error createOAuth2Error(String reason) {
|
||||
return new OAuth2Error(OAuth2ErrorCodes.INVALID_DPOP_PROOF, reason, null);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private static final class OAuth2AccessTokenClaims implements OAuth2Token, ClaimAccessor {
|
||||
|
||||
private final OAuth2Token accessToken;
|
||||
|
||||
+4
-3
@@ -51,6 +51,7 @@ import org.springframework.security.core.userdetails.UserDetails;
|
||||
import org.springframework.security.core.userdetails.UserDetailsService;
|
||||
import org.springframework.security.core.userdetails.UsernameNotFoundException;
|
||||
import org.springframework.security.core.userdetails.cache.NullUserCache;
|
||||
import org.springframework.security.crypto.codec.Utf8;
|
||||
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
|
||||
import org.springframework.security.web.context.RequestAttributeSecurityContextRepository;
|
||||
import org.springframework.security.web.context.SecurityContextRepository;
|
||||
@@ -167,7 +168,7 @@ public class DigestAuthenticationFilter extends GenericFilterBean implements Mes
|
||||
}
|
||||
serverDigestMd5 = digestAuth.calculateServerDigest(user.getPassword(), request.getMethod());
|
||||
// If digest is incorrect, try refreshing from backend and recomputing
|
||||
if (!serverDigestMd5.equals(digestAuth.getResponse()) && cacheWasUsed) {
|
||||
if (!Utf8.isEqual(serverDigestMd5, digestAuth.getResponse()) && cacheWasUsed) {
|
||||
logger.debug("Digest comparison failure; trying to refresh user from DAO in case password had changed");
|
||||
user = this.userDetailsService.loadUserByUsername(username);
|
||||
this.userCache.putUserInCache(user);
|
||||
@@ -181,7 +182,7 @@ public class DigestAuthenticationFilter extends GenericFilterBean implements Mes
|
||||
return;
|
||||
}
|
||||
// If digest is still incorrect, definitely reject authentication attempt
|
||||
if (!serverDigestMd5.equals(digestAuth.getResponse())) {
|
||||
if (!Utf8.isEqual(serverDigestMd5, digestAuth.getResponse())) {
|
||||
logger.debug(LogMessage.format(
|
||||
"Expected response: '%s' but received: '%s'; is AuthenticationDao returning clear text passwords?",
|
||||
serverDigestMd5, digestAuth.getResponse()));
|
||||
@@ -405,7 +406,7 @@ public class DigestAuthenticationFilter extends GenericFilterBean implements Mes
|
||||
}
|
||||
// Check signature of nonce matches this expiry time
|
||||
String expectedNonceSignature = DigestAuthUtils.md5Hex(this.nonceExpiryTime + ":" + entryPointKey);
|
||||
if (!expectedNonceSignature.equals(nonceTokens[1])) {
|
||||
if (!Utf8.isEqual(expectedNonceSignature, nonceTokens[1])) {
|
||||
throw new BadCredentialsException(DigestAuthenticationFilter.this.messages.getMessage(
|
||||
"DigestAuthenticationFilter.nonceCompromised", new Object[] { nonceAsPlainText },
|
||||
"Nonce token compromised {0}"));
|
||||
|
||||
+22
@@ -16,6 +16,7 @@
|
||||
|
||||
package org.springframework.security.web.webauthn.api;
|
||||
|
||||
import java.io.ObjectStreamException;
|
||||
import java.io.Serial;
|
||||
import java.io.Serializable;
|
||||
|
||||
@@ -121,4 +122,25 @@ public final class AuthenticatorTransport implements Serializable {
|
||||
return new AuthenticatorTransport[] { USB, NFC, BLE, HYBRID, INTERNAL };
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object obj) {
|
||||
if (this == obj) {
|
||||
return true;
|
||||
}
|
||||
if (!(obj instanceof AuthenticatorTransport other)) {
|
||||
return false;
|
||||
}
|
||||
return this.value.equals(other.value);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return this.value.hashCode();
|
||||
}
|
||||
|
||||
@Serial
|
||||
private Object readResolve() throws ObjectStreamException {
|
||||
return valueOf(this.value);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+22
@@ -16,6 +16,7 @@
|
||||
|
||||
package org.springframework.security.web.webauthn.api;
|
||||
|
||||
import java.io.ObjectStreamException;
|
||||
import java.io.Serial;
|
||||
import java.io.Serializable;
|
||||
|
||||
@@ -58,4 +59,25 @@ public final class PublicKeyCredentialType implements Serializable {
|
||||
return new PublicKeyCredentialType(value);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object obj) {
|
||||
if (this == obj) {
|
||||
return true;
|
||||
}
|
||||
if (!(obj instanceof PublicKeyCredentialType other)) {
|
||||
return false;
|
||||
}
|
||||
return this.value.equals(other.value);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return this.value.hashCode();
|
||||
}
|
||||
|
||||
@Serial
|
||||
private Object readResolve() throws ObjectStreamException {
|
||||
return valueOf(this.value);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+33
@@ -16,9 +16,12 @@
|
||||
|
||||
package org.springframework.security.web.webauthn.api;
|
||||
|
||||
import java.io.ObjectStreamException;
|
||||
import java.io.Serial;
|
||||
import java.io.Serializable;
|
||||
|
||||
import org.jspecify.annotations.Nullable;
|
||||
|
||||
/**
|
||||
* <a href=
|
||||
* "https://www.w3.org/TR/webauthn-3/#enumdef-userverificationrequirement">UserVerificationRequirement</a>
|
||||
@@ -72,4 +75,34 @@ public final class UserVerificationRequirement implements Serializable {
|
||||
return this.value;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(@Nullable Object obj) {
|
||||
if (this == obj) {
|
||||
return true;
|
||||
}
|
||||
if (!(obj instanceof UserVerificationRequirement other)) {
|
||||
return false;
|
||||
}
|
||||
return this.value.equals(other.value);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return this.value.hashCode();
|
||||
}
|
||||
|
||||
@Serial
|
||||
private Object readResolve() throws ObjectStreamException {
|
||||
switch (this.value) {
|
||||
case "required":
|
||||
return REQUIRED;
|
||||
case "preferred":
|
||||
return PREFERRED;
|
||||
case "discouraged":
|
||||
return DISCOURAGED;
|
||||
default:
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+5
-5
@@ -258,8 +258,8 @@ public class Webauthn4JRelyingPartyOperations implements WebAuthnRelyingPartyOpe
|
||||
byte[] clientDataJSON = response.getClientDataJSON().getBytes();
|
||||
Challenge challenge = new DefaultChallenge(base64Challenge);
|
||||
ServerProperty serverProperty = new ServerProperty(origins, rpId, challenge);
|
||||
boolean userVerificationRequired = creationOptions.getAuthenticatorSelection()
|
||||
.getUserVerification() == UserVerificationRequirement.REQUIRED;
|
||||
boolean userVerificationRequired = UserVerificationRequirement.REQUIRED
|
||||
.equals(creationOptions.getAuthenticatorSelection().getUserVerification());
|
||||
// requireUserPresence The constant Boolean value true
|
||||
// https://www.w3.org/TR/webauthn-3/#sctn-op-make-cred
|
||||
boolean userPresenceRequired = true;
|
||||
@@ -318,7 +318,7 @@ public class Webauthn4JRelyingPartyOperations implements WebAuthnRelyingPartyOpe
|
||||
|
||||
private com.webauthn4j.data.PublicKeyCredentialParameters convertParamToWebauthn4j(
|
||||
PublicKeyCredentialParameters parameter) {
|
||||
if (parameter.getType() != PublicKeyCredentialType.PUBLIC_KEY) {
|
||||
if (!PublicKeyCredentialType.PUBLIC_KEY.equals(parameter.getType())) {
|
||||
throw new IllegalArgumentException(
|
||||
"Cannot convert unknown credential type " + parameter.getType() + " to webauthn4j");
|
||||
}
|
||||
@@ -395,8 +395,8 @@ public class Webauthn4JRelyingPartyOperations implements WebAuthnRelyingPartyOpe
|
||||
String rpId = requestOptions.getRpId();
|
||||
Assert.notNull(rpId, "rpId cannot be null");
|
||||
ServerProperty serverProperty = new ServerProperty(origins, rpId, challenge);
|
||||
boolean userVerificationRequired = request.getRequestOptions()
|
||||
.getUserVerification() == UserVerificationRequirement.REQUIRED;
|
||||
boolean userVerificationRequired = UserVerificationRequirement.REQUIRED
|
||||
.equals(request.getRequestOptions().getUserVerification());
|
||||
|
||||
com.webauthn4j.data.AuthenticationRequest authenticationRequest = new com.webauthn4j.data.AuthenticationRequest(
|
||||
request.getPublicKey().getRawId().getBytes(), assertionResponse.getAuthenticatorData().getBytes(),
|
||||
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
/*
|
||||
* 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.web.webauthn.api;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/**
|
||||
* Verifies that {@link AuthenticatorAttachment} correctly restores singleton identity
|
||||
* after Java deserialization. This class already implements {@code readResolve()} and
|
||||
* serves as the reference for the pattern required by the other pseudo-enum types.
|
||||
*/
|
||||
class AuthenticatorAttachmentTests {
|
||||
|
||||
@Test
|
||||
void platformWhenDeserializedThenSameAsConstant() {
|
||||
assertThat(SerializationTestUtils.serializeAndDeserialize(AuthenticatorAttachment.PLATFORM))
|
||||
.isSameAs(AuthenticatorAttachment.PLATFORM);
|
||||
}
|
||||
|
||||
@Test
|
||||
void crossPlatformWhenDeserializedThenSameAsConstant() {
|
||||
assertThat(SerializationTestUtils.serializeAndDeserialize(AuthenticatorAttachment.CROSS_PLATFORM))
|
||||
.isSameAs(AuthenticatorAttachment.CROSS_PLATFORM);
|
||||
}
|
||||
|
||||
}
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
/*
|
||||
* 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.web.webauthn.api;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class AuthenticatorTransportTests {
|
||||
|
||||
@Test
|
||||
void usbWhenDeserializedThenSameAsConstant() {
|
||||
assertThat(SerializationTestUtils.serializeAndDeserialize(AuthenticatorTransport.USB))
|
||||
.isSameAs(AuthenticatorTransport.USB);
|
||||
}
|
||||
|
||||
@Test
|
||||
void nfcWhenDeserializedThenSameAsConstant() {
|
||||
assertThat(SerializationTestUtils.serializeAndDeserialize(AuthenticatorTransport.NFC))
|
||||
.isSameAs(AuthenticatorTransport.NFC);
|
||||
}
|
||||
|
||||
@Test
|
||||
void bleWhenDeserializedThenSameAsConstant() {
|
||||
assertThat(SerializationTestUtils.serializeAndDeserialize(AuthenticatorTransport.BLE))
|
||||
.isSameAs(AuthenticatorTransport.BLE);
|
||||
}
|
||||
|
||||
@Test
|
||||
void smartCardWhenDeserializedThenSameAsConstant() {
|
||||
assertThat(SerializationTestUtils.serializeAndDeserialize(AuthenticatorTransport.SMART_CARD))
|
||||
.isSameAs(AuthenticatorTransport.SMART_CARD);
|
||||
}
|
||||
|
||||
@Test
|
||||
void hybridWhenDeserializedThenSameAsConstant() {
|
||||
assertThat(SerializationTestUtils.serializeAndDeserialize(AuthenticatorTransport.HYBRID))
|
||||
.isSameAs(AuthenticatorTransport.HYBRID);
|
||||
}
|
||||
|
||||
@Test
|
||||
void internalWhenDeserializedThenSameAsConstant() {
|
||||
assertThat(SerializationTestUtils.serializeAndDeserialize(AuthenticatorTransport.INTERNAL))
|
||||
.isSameAs(AuthenticatorTransport.INTERNAL);
|
||||
}
|
||||
|
||||
@Test
|
||||
void usbWhenSameValueThenEquals() {
|
||||
assertThat(new AuthenticatorTransport("usb")).isEqualTo(AuthenticatorTransport.USB);
|
||||
}
|
||||
|
||||
@Test
|
||||
void usbWhenSameValueThenHashCodeMatches() {
|
||||
assertThat(new AuthenticatorTransport("usb")).hasSameHashCodeAs(AuthenticatorTransport.USB);
|
||||
}
|
||||
|
||||
@Test
|
||||
void usbWhenDifferentValueThenNotEquals() {
|
||||
assertThat(AuthenticatorTransport.USB).isNotEqualTo(AuthenticatorTransport.NFC);
|
||||
}
|
||||
|
||||
}
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
/*
|
||||
* 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.web.webauthn.api;
|
||||
|
||||
import java.util.Set;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class PublicKeyCredentialDescriptorTests {
|
||||
|
||||
@Test
|
||||
void typeWhenDeserializedThenSameAsConstant() {
|
||||
PublicKeyCredentialDescriptor descriptor = PublicKeyCredentialDescriptor.builder()
|
||||
.id(TestBytes.get())
|
||||
.type(PublicKeyCredentialType.PUBLIC_KEY)
|
||||
.build();
|
||||
PublicKeyCredentialDescriptor deserialized = SerializationTestUtils.serializeAndDeserialize(descriptor);
|
||||
assertThat(deserialized.getType()).isSameAs(PublicKeyCredentialType.PUBLIC_KEY);
|
||||
}
|
||||
|
||||
@Test
|
||||
void transportsWhenDeserializedThenSameAsConstants() {
|
||||
PublicKeyCredentialDescriptor descriptor = PublicKeyCredentialDescriptor.builder()
|
||||
.id(TestBytes.get())
|
||||
.type(PublicKeyCredentialType.PUBLIC_KEY)
|
||||
.transports(Set.of(AuthenticatorTransport.USB, AuthenticatorTransport.HYBRID))
|
||||
.build();
|
||||
PublicKeyCredentialDescriptor deserialized = SerializationTestUtils.serializeAndDeserialize(descriptor);
|
||||
assertThat(deserialized.getTransports()).containsExactlyInAnyOrder(AuthenticatorTransport.USB,
|
||||
AuthenticatorTransport.HYBRID);
|
||||
}
|
||||
|
||||
}
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
/*
|
||||
* 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.web.webauthn.api;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class PublicKeyCredentialRequestOptionsTests {
|
||||
|
||||
@Test
|
||||
void userVerificationRequiredWhenDeserializedThenSameAsConstant() {
|
||||
PublicKeyCredentialRequestOptions options = TestPublicKeyCredentialRequestOptions.create()
|
||||
.userVerification(UserVerificationRequirement.REQUIRED)
|
||||
.build();
|
||||
PublicKeyCredentialRequestOptions deserialized = SerializationTestUtils.serializeAndDeserialize(options);
|
||||
assertThat(deserialized.getUserVerification()).isSameAs(UserVerificationRequirement.REQUIRED);
|
||||
}
|
||||
|
||||
@Test
|
||||
void userVerificationPreferredWhenDeserializedThenSameAsConstant() {
|
||||
PublicKeyCredentialRequestOptions options = TestPublicKeyCredentialRequestOptions.create()
|
||||
.userVerification(UserVerificationRequirement.PREFERRED)
|
||||
.build();
|
||||
PublicKeyCredentialRequestOptions deserialized = SerializationTestUtils.serializeAndDeserialize(options);
|
||||
assertThat(deserialized.getUserVerification()).isSameAs(UserVerificationRequirement.PREFERRED);
|
||||
}
|
||||
|
||||
}
|
||||
+51
@@ -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.web.webauthn.api;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class PublicKeyCredentialTypeTests {
|
||||
|
||||
@Test
|
||||
void publicKeyWhenDeserializedThenSameAsConstant() {
|
||||
assertThat(SerializationTestUtils.serializeAndDeserialize(PublicKeyCredentialType.PUBLIC_KEY))
|
||||
.isSameAs(PublicKeyCredentialType.PUBLIC_KEY);
|
||||
}
|
||||
|
||||
@Test
|
||||
void publicKeyWhenSameValueThenEquals() {
|
||||
PublicKeyCredentialType first = PublicKeyCredentialType.valueOf("custom");
|
||||
PublicKeyCredentialType second = PublicKeyCredentialType.valueOf("custom");
|
||||
assertThat(first).isEqualTo(second);
|
||||
}
|
||||
|
||||
@Test
|
||||
void publicKeyWhenSameValueThenHashCodeMatches() {
|
||||
PublicKeyCredentialType first = PublicKeyCredentialType.valueOf("custom");
|
||||
PublicKeyCredentialType second = PublicKeyCredentialType.valueOf("custom");
|
||||
assertThat(first).hasSameHashCodeAs(second);
|
||||
}
|
||||
|
||||
@Test
|
||||
void publicKeyWhenDifferentValueThenNotEquals() {
|
||||
assertThat(PublicKeyCredentialType.valueOf("custom-1"))
|
||||
.isNotEqualTo(PublicKeyCredentialType.valueOf("custom-2"));
|
||||
}
|
||||
|
||||
}
|
||||
+47
@@ -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.web.webauthn.api;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.ObjectInputStream;
|
||||
import java.io.ObjectOutputStream;
|
||||
import java.io.Serializable;
|
||||
|
||||
public final class SerializationTestUtils {
|
||||
|
||||
private SerializationTestUtils() {
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
public static <T extends Serializable> T serializeAndDeserialize(T object) {
|
||||
try {
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
try (ObjectOutputStream oos = new ObjectOutputStream(baos)) {
|
||||
oos.writeObject(object);
|
||||
}
|
||||
ByteArrayInputStream bais = new ByteArrayInputStream(baos.toByteArray());
|
||||
try (ObjectInputStream ois = new ObjectInputStream(bais)) {
|
||||
return (T) ois.readObject();
|
||||
}
|
||||
}
|
||||
catch (Exception ex) {
|
||||
throw new RuntimeException("Serialization round-trip failed", ex);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
/*
|
||||
* 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.web.webauthn.api;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class UserVerificationRequirementTests {
|
||||
|
||||
@Test
|
||||
void requiredWhenDeserializedThenSameAsConstant() {
|
||||
assertThat(SerializationTestUtils.serializeAndDeserialize(UserVerificationRequirement.REQUIRED))
|
||||
.isSameAs(UserVerificationRequirement.REQUIRED);
|
||||
}
|
||||
|
||||
@Test
|
||||
void preferredWhenDeserializedThenSameAsConstant() {
|
||||
assertThat(SerializationTestUtils.serializeAndDeserialize(UserVerificationRequirement.PREFERRED))
|
||||
.isSameAs(UserVerificationRequirement.PREFERRED);
|
||||
}
|
||||
|
||||
@Test
|
||||
void discouragedWhenDeserializedThenSameAsConstant() {
|
||||
assertThat(SerializationTestUtils.serializeAndDeserialize(UserVerificationRequirement.DISCOURAGED))
|
||||
.isSameAs(UserVerificationRequirement.DISCOURAGED);
|
||||
}
|
||||
|
||||
@Test
|
||||
void requiredWhenSameValueThenEquals() {
|
||||
assertThat(new UserVerificationRequirement("required")).isEqualTo(UserVerificationRequirement.REQUIRED);
|
||||
}
|
||||
|
||||
@Test
|
||||
void requiredWhenSameValueThenHashCodeMatches() {
|
||||
assertThat(new UserVerificationRequirement("required")).hasSameHashCodeAs(UserVerificationRequirement.REQUIRED);
|
||||
}
|
||||
|
||||
@Test
|
||||
void requiredWhenDifferentValueThenNotEquals() {
|
||||
assertThat(UserVerificationRequirement.REQUIRED).isNotEqualTo(UserVerificationRequirement.PREFERRED);
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user