From a856baa6a817dbb2be39b5d7180020207b4abb91 Mon Sep 17 00:00:00 2001 From: Robert Winch <362503+rwinch@users.noreply.github.com> Date: Sun, 29 Mar 2026 21:45:18 -0500 Subject: [PATCH] Add CredentialRecordOwnerAuthorizationManager Add CredentialRecordOwnerAuthorizationManager that verifies the credential being deleted is owned by the currently authenticated user. Also add an AuthorizationManager to WebAuthnRegistrationFilter for the delete credential operation, defaulting to deny all, and wire it up in WebAuthnConfigurer. Per the WebAuthn specification [1], credential ids contain at least 16 bytes with at least 100 bits of entropy, making them practically unguessable. The specification also advises that credential ids should be kept private, as exposing them can leak personally identifying information [2]. The CredentialRecordOwnerAuthorizationManager serves as defense in depth: even if a credential id were somehow exposed, an unauthorized user could not delete another user's credential. [1] https://www.w3.org/TR/webauthn-3/#credential-id [2] https://www.w3.org/TR/webauthn-3/#sctn-credential-id-privacy-leak --- .../web/configurers/WebAuthnConfigurer.java | 3 + .../configurers/WebAuthnConfigurerTests.java | 69 ++++++++ ...entialRecordOwnerAuthorizationManager.java | 91 +++++++++++ .../WebAuthnRegistrationFilter.java | 59 ++++++- ...lRecordOwnerAuthorizationManagerTests.java | 154 ++++++++++++++++++ .../WebAuthnRegistrationFilterTests.java | 33 ++++ 6 files changed, 408 insertions(+), 1 deletion(-) create mode 100644 web/src/main/java/org/springframework/security/web/webauthn/management/CredentialRecordOwnerAuthorizationManager.java create mode 100644 web/src/test/java/org/springframework/security/web/webauthn/management/CredentialRecordOwnerAuthorizationManagerTests.java diff --git a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/WebAuthnConfigurer.java b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/WebAuthnConfigurer.java index b1c3ce32bf..07674f1d3a 100644 --- a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/WebAuthnConfigurer.java +++ b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/WebAuthnConfigurer.java @@ -36,6 +36,7 @@ import org.springframework.security.web.webauthn.api.PublicKeyCredentialRpEntity import org.springframework.security.web.webauthn.authentication.PublicKeyCredentialRequestOptionsFilter; import org.springframework.security.web.webauthn.authentication.WebAuthnAuthenticationFilter; import org.springframework.security.web.webauthn.authentication.WebAuthnAuthenticationProvider; +import org.springframework.security.web.webauthn.management.CredentialRecordOwnerAuthorizationManager; import org.springframework.security.web.webauthn.management.MapPublicKeyCredentialUserEntityRepository; import org.springframework.security.web.webauthn.management.MapUserCredentialRepository; import org.springframework.security.web.webauthn.management.PublicKeyCredentialUserEntityRepository; @@ -166,6 +167,8 @@ public class WebAuthnConfigurer> new ProviderManager(new WebAuthnAuthenticationProvider(rpOperations, userDetailsService))); WebAuthnRegistrationFilter webAuthnRegistrationFilter = new WebAuthnRegistrationFilter(userCredentials, rpOperations); + webAuthnRegistrationFilter.setDeleteCredentialAuthorizationManager( + new CredentialRecordOwnerAuthorizationManager(userCredentials, userEntities)); PublicKeyCredentialCreationOptionsFilter creationOptionsFilter = new PublicKeyCredentialCreationOptionsFilter( rpOperations); if (creationOptionsRepository != null) { diff --git a/config/src/test/java/org/springframework/security/config/annotation/web/configurers/WebAuthnConfigurerTests.java b/config/src/test/java/org/springframework/security/config/annotation/web/configurers/WebAuthnConfigurerTests.java index 6dffd8b3e8..c7ec976272 100644 --- a/config/src/test/java/org/springframework/security/config/annotation/web/configurers/WebAuthnConfigurerTests.java +++ b/config/src/test/java/org/springframework/security/config/annotation/web/configurers/WebAuthnConfigurerTests.java @@ -42,8 +42,15 @@ import org.springframework.security.provisioning.InMemoryUserDetailsManager; import org.springframework.security.web.FilterChainProxy; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.ui.DefaultResourcesFilter; +import org.springframework.security.web.webauthn.api.Bytes; +import org.springframework.security.web.webauthn.api.ImmutablePublicKeyCredentialUserEntity; import org.springframework.security.web.webauthn.api.PublicKeyCredentialCreationOptions; +import org.springframework.security.web.webauthn.api.TestCredentialRecords; import org.springframework.security.web.webauthn.api.TestPublicKeyCredentialCreationOptions; +import org.springframework.security.web.webauthn.management.MapPublicKeyCredentialUserEntityRepository; +import org.springframework.security.web.webauthn.management.MapUserCredentialRepository; +import org.springframework.security.web.webauthn.management.PublicKeyCredentialUserEntityRepository; +import org.springframework.security.web.webauthn.management.UserCredentialRepository; import org.springframework.security.web.webauthn.management.WebAuthnRelyingPartyOperations; import org.springframework.security.web.webauthn.registration.HttpSessionPublicKeyCredentialCreationOptionsRepository; import org.springframework.test.web.servlet.MockMvc; @@ -56,6 +63,7 @@ import static org.mockito.BDDMockito.willAnswer; import static org.mockito.Mockito.mock; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; @@ -247,6 +255,24 @@ public class WebAuthnConfigurerTests { .andExpect(content().string(expectedBody)); } + @Test + void webauthnWhenDeleteAndCredentialBelongsToUserThenNoContent() throws Exception { + this.spring.register(DeleteCredentialConfiguration.class).autowire(); + this.mvc + .perform(delete("/webauthn/register/" + DeleteCredentialConfiguration.CREDENTIAL_ID_BASE64URL) + .with(authentication(new TestingAuthenticationToken("user", "password", "ROLE_USER")))) + .andExpect(status().isNoContent()); + } + + @Test + void webauthnWhenDeleteAndCredentialBelongsToDifferentUserThenForbidden() throws Exception { + this.spring.register(DeleteCredentialConfiguration.class).autowire(); + this.mvc + .perform(delete("/webauthn/register/" + DeleteCredentialConfiguration.CREDENTIAL_ID_BASE64URL) + .with(authentication(new TestingAuthenticationToken("other-user", "password", "ROLE_USER")))) + .andExpect(status().isForbidden()); + } + @Configuration @EnableWebSecurity static class ConfigCredentialCreationOptionsRepository { @@ -417,4 +443,47 @@ public class WebAuthnConfigurerTests { } + @Configuration + @EnableWebSecurity + static class DeleteCredentialConfiguration { + + static final String CREDENTIAL_ID_BASE64URL = "NauGCN7bZ5jEBwThcde51g"; + + static final Bytes USER_ENTITY_ID = Bytes.fromBase64("vKBFhsWT3gQnn-gHdT4VXIvjDkVXVYg5w8CLGHPunMM"); + + @Bean + UserDetailsService userDetailsService() { + return new InMemoryUserDetailsManager(); + } + + @Bean + WebAuthnRelyingPartyOperations webAuthnRelyingPartyOperations() { + return mock(WebAuthnRelyingPartyOperations.class); + } + + @Bean + UserCredentialRepository userCredentialRepository() { + MapUserCredentialRepository repository = new MapUserCredentialRepository(); + repository.save(TestCredentialRecords.userCredential().build()); + return repository; + } + + @Bean + PublicKeyCredentialUserEntityRepository userEntityRepository() { + MapPublicKeyCredentialUserEntityRepository repository = new MapPublicKeyCredentialUserEntityRepository(); + repository.save(ImmutablePublicKeyCredentialUserEntity.builder() + .name("user") + .id(USER_ENTITY_ID) + .displayName("User") + .build()); + return repository; + } + + @Bean + SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + return http.csrf(AbstractHttpConfigurer::disable).webAuthn(Customizer.withDefaults()).build(); + } + + } + } diff --git a/web/src/main/java/org/springframework/security/web/webauthn/management/CredentialRecordOwnerAuthorizationManager.java b/web/src/main/java/org/springframework/security/web/webauthn/management/CredentialRecordOwnerAuthorizationManager.java new file mode 100644 index 0000000000..4484439803 --- /dev/null +++ b/web/src/main/java/org/springframework/security/web/webauthn/management/CredentialRecordOwnerAuthorizationManager.java @@ -0,0 +1,91 @@ +/* + * 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.management; + +import java.util.function.Supplier; + +import org.springframework.security.authorization.AuthenticatedAuthorizationManager; +import org.springframework.security.authorization.AuthorizationDecision; +import org.springframework.security.authorization.AuthorizationManager; +import org.springframework.security.core.Authentication; +import org.springframework.security.web.webauthn.api.Bytes; +import org.springframework.security.web.webauthn.api.CredentialRecord; +import org.springframework.security.web.webauthn.api.PublicKeyCredentialUserEntity; +import org.springframework.util.Assert; + +/** + * An {@link AuthorizationManager} that grants access when the {@link CredentialRecord} + * identified by the provided credential id is owned by the currently authenticated user. + * + *

+ * Per the WebAuthn + * specification, a credential id must contain at least 16 bytes with at least 100 + * bits of entropy, making it practically unguessable. The specification also advises that + * credential ids should be kept private, as exposing them can leak personally identifying + * information (see + * § 14.6.3 + * Privacy leak via credential IDs). This {@link AuthorizationManager} is therefore + * intended as defense in depth: even if a credential id were somehow exposed, an + * unauthorized user could not delete another user's credential. + * + * @author Rob Winch + * @since 6.5.10 + */ +public final class CredentialRecordOwnerAuthorizationManager implements AuthorizationManager { + + private final AuthenticatedAuthorizationManager authenticatedAuthorizationManager = AuthenticatedAuthorizationManager + .authenticated(); + + private final UserCredentialRepository userCredentials; + + private final PublicKeyCredentialUserEntityRepository userEntities; + + /** + * Creates a new instance. + * @param userCredentials the {@link UserCredentialRepository} to use + * @param userEntities the {@link PublicKeyCredentialUserEntityRepository} to use + */ + public CredentialRecordOwnerAuthorizationManager(UserCredentialRepository userCredentials, + PublicKeyCredentialUserEntityRepository userEntities) { + Assert.notNull(userCredentials, "userCredentials cannot be null"); + Assert.notNull(userEntities, "userEntities cannot be null"); + this.userCredentials = userCredentials; + this.userEntities = userEntities; + } + + @Override + public AuthorizationDecision check(Supplier authentication, Bytes credentialId) { + AuthorizationDecision decision = this.authenticatedAuthorizationManager.check(authentication, credentialId); + if (!decision.isGranted()) { + return decision; + } + Authentication auth = authentication.get(); + CredentialRecord credential = this.userCredentials.findByCredentialId(credentialId); + if (credential == null) { + return new AuthorizationDecision(false); + } + if (credential.getUserEntityUserId() == null) { + return new AuthorizationDecision(false); + } + PublicKeyCredentialUserEntity userEntity = this.userEntities.findByUsername(auth.getName()); + if (userEntity == null) { + return new AuthorizationDecision(false); + } + return new AuthorizationDecision(credential.getUserEntityUserId().equals(userEntity.getId())); + } + +} diff --git a/web/src/main/java/org/springframework/security/web/webauthn/registration/WebAuthnRegistrationFilter.java b/web/src/main/java/org/springframework/security/web/webauthn/registration/WebAuthnRegistrationFilter.java index 46694fca03..3317d0e816 100644 --- a/web/src/main/java/org/springframework/security/web/webauthn/registration/WebAuthnRegistrationFilter.java +++ b/web/src/main/java/org/springframework/security/web/webauthn/registration/WebAuthnRegistrationFilter.java @@ -17,6 +17,7 @@ package org.springframework.security.web.webauthn.registration; import java.io.IOException; +import java.util.function.Supplier; import com.fasterxml.jackson.databind.json.JsonMapper; import jakarta.servlet.FilterChain; @@ -34,6 +35,12 @@ import org.springframework.http.converter.HttpMessageConverter; import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter; import org.springframework.http.server.ServletServerHttpRequest; import org.springframework.http.server.ServletServerHttpResponse; +import org.springframework.security.authorization.AuthorizationManager; +import org.springframework.security.authorization.AuthorizationResult; +import org.springframework.security.authorization.SingleResultAuthorizationManager; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.context.SecurityContextHolderStrategy; import org.springframework.security.web.servlet.util.matcher.PathPatternRequestMatcher; import org.springframework.security.web.util.matcher.RequestMatcher; import org.springframework.security.web.webauthn.api.Bytes; @@ -87,6 +94,9 @@ public class WebAuthnRegistrationFilter extends OncePerRequestFilter { private final UserCredentialRepository userCredentials; + private SecurityContextHolderStrategy securityContextHolderStrategy = SecurityContextHolder + .getContextHolderStrategy(); + private HttpMessageConverter converter = new MappingJackson2HttpMessageConverter( JsonMapper.builder().addModule(new WebauthnJackson2Module()).build()); @@ -98,6 +108,9 @@ public class WebAuthnRegistrationFilter extends OncePerRequestFilter { private RequestMatcher removeCredentialMatcher = PathPatternRequestMatcher.withDefaults() .matcher(HttpMethod.DELETE, "/webauthn/register/{id}"); + private AuthorizationManager deleteCredentialAuthorizationManager = SingleResultAuthorizationManager + .denyAll(); + public WebAuthnRegistrationFilter(UserCredentialRepository userCredentials, WebAuthnRelyingPartyOperations rpOptions) { Assert.notNull(userCredentials, "userCredentials must not be null"); @@ -132,6 +145,42 @@ public class WebAuthnRegistrationFilter extends OncePerRequestFilter { this.removeCredentialMatcher = removeCredentialMatcher; } + /** + * Sets the {@link AuthorizationManager} used to authorize the delete credential + * operation. The object being authorized is the credential id as {@link Bytes}. By + * default, all delete requests are denied. + * + *

+ * Per the WebAuthn + * specification, a credential id must contain at least 16 bytes with at least 100 + * bits of entropy, making it practically unguessable. The specification also advises + * that credential ids should be kept private, as exposing them can leak personally + * identifying information (see + * § + * 14.6.3 Privacy leak via credential IDs). This {@link AuthorizationManager} is + * therefore intended as defense in depth: even if a credential id were somehow + * exposed, an unauthorized user could not delete another user's credential. + * @param deleteCredentialAuthorizationManager the {@link AuthorizationManager} to use + * @since 6.5.10 + */ + public void setDeleteCredentialAuthorizationManager( + AuthorizationManager deleteCredentialAuthorizationManager) { + Assert.notNull(deleteCredentialAuthorizationManager, "deleteCredentialAuthorizationManager cannot be null"); + this.deleteCredentialAuthorizationManager = deleteCredentialAuthorizationManager; + } + + /** + * Sets the {@link SecurityContextHolderStrategy} to use. The default is + * {@link SecurityContextHolder#getContextHolderStrategy()}. + * @param securityContextHolderStrategy the {@link SecurityContextHolderStrategy} to + * use + * @since 6.5.10 + */ + public void setSecurityContextHolderStrategy(SecurityContextHolderStrategy securityContextHolderStrategy) { + Assert.notNull(securityContextHolderStrategy, "securityContextHolderStrategy cannot be null"); + this.securityContextHolderStrategy = securityContextHolderStrategy; + } + @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { @@ -203,7 +252,15 @@ public class WebAuthnRegistrationFilter extends OncePerRequestFilter { private void removeCredential(HttpServletRequest request, HttpServletResponse response, String id) throws IOException { - this.userCredentials.delete(Bytes.fromBase64(id)); + Bytes credentialId = Bytes.fromBase64(id); + Supplier authentication = () -> this.securityContextHolderStrategy.getContext() + .getAuthentication(); + AuthorizationResult result = this.deleteCredentialAuthorizationManager.authorize(authentication, credentialId); + if (result != null && !result.isGranted()) { + response.setStatus(HttpStatus.FORBIDDEN.value()); + return; + } + this.userCredentials.delete(credentialId); response.setStatus(HttpStatus.NO_CONTENT.value()); } diff --git a/web/src/test/java/org/springframework/security/web/webauthn/management/CredentialRecordOwnerAuthorizationManagerTests.java b/web/src/test/java/org/springframework/security/web/webauthn/management/CredentialRecordOwnerAuthorizationManagerTests.java new file mode 100644 index 0000000000..5012a1e006 --- /dev/null +++ b/web/src/test/java/org/springframework/security/web/webauthn/management/CredentialRecordOwnerAuthorizationManagerTests.java @@ -0,0 +1,154 @@ +/* + * 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.management; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import org.springframework.security.authentication.TestingAuthenticationToken; +import org.springframework.security.authorization.AuthorizationDecision; +import org.springframework.security.web.webauthn.api.Bytes; +import org.springframework.security.web.webauthn.api.ImmutablePublicKeyCredentialUserEntity; +import org.springframework.security.web.webauthn.api.PublicKeyCredentialUserEntity; +import org.springframework.security.web.webauthn.api.TestBytes; +import org.springframework.security.web.webauthn.api.TestCredentialRecords; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; +import static org.mockito.BDDMockito.given; + +/** + * Tests for {@link CredentialRecordOwnerAuthorizationManager}. + * + * @author Rob Winch + * @since 6.5.10 + */ +@ExtendWith(MockitoExtension.class) +class CredentialRecordOwnerAuthorizationManagerTests { + + @Mock + private UserCredentialRepository userCredentials; + + @Mock + private PublicKeyCredentialUserEntityRepository userEntities; + + @Test + void constructorWhenNullUserCredentialsThenIllegalArgument() { + assertThatIllegalArgumentException() + .isThrownBy(() -> new CredentialRecordOwnerAuthorizationManager(null, this.userEntities)); + } + + @Test + void constructorWhenNullUserEntitiesTonIllegalArgument() { + assertThatIllegalArgumentException() + .isThrownBy(() -> new CredentialRecordOwnerAuthorizationManager(this.userCredentials, null)); + } + + @Test + void checkWhenAuthenticationNullThenDenied() { + CredentialRecordOwnerAuthorizationManager manager = new CredentialRecordOwnerAuthorizationManager( + this.userCredentials, this.userEntities); + Bytes credentialId = TestCredentialRecords.userCredential().build().getCredentialId(); + AuthorizationDecision decision = manager.check(() -> null, credentialId); + assertThat(decision.isGranted()).isFalse(); + } + + @Test + void checkWhenNotAuthenticatedThenDenied() { + CredentialRecordOwnerAuthorizationManager manager = new CredentialRecordOwnerAuthorizationManager( + this.userCredentials, this.userEntities); + Bytes credentialId = TestCredentialRecords.userCredential().build().getCredentialId(); + TestingAuthenticationToken authentication = new TestingAuthenticationToken("user", "password"); + authentication.setAuthenticated(false); + AuthorizationDecision decision = manager.check(() -> authentication, credentialId); + assertThat(decision.isGranted()).isFalse(); + } + + @Test + void checkWhenCredentialNotFoundThenDenied() { + CredentialRecordOwnerAuthorizationManager manager = new CredentialRecordOwnerAuthorizationManager( + this.userCredentials, this.userEntities); + Bytes credentialId = TestCredentialRecords.userCredential().build().getCredentialId(); + TestingAuthenticationToken authentication = new TestingAuthenticationToken("user", "password", "USER"); + AuthorizationDecision decision = manager.check(() -> authentication, credentialId); + assertThat(decision.isGranted()).isFalse(); + } + + @Test + void checkWhenCredentialUserEntityUserIdNullThenDenied() { + CredentialRecordOwnerAuthorizationManager manager = new CredentialRecordOwnerAuthorizationManager( + this.userCredentials, this.userEntities); + Bytes credentialId = TestCredentialRecords.userCredential().build().getCredentialId(); + given(this.userCredentials.findByCredentialId(credentialId)) + .willReturn(TestCredentialRecords.userCredential().userEntityUserId(null).build()); + TestingAuthenticationToken authentication = new TestingAuthenticationToken("user", "password", "USER"); + AuthorizationDecision decision = manager.check(() -> authentication, credentialId); + assertThat(decision.isGranted()).isFalse(); + } + + @Test + void checkWhenUserEntityNotFoundThenDenied() { + CredentialRecordOwnerAuthorizationManager manager = new CredentialRecordOwnerAuthorizationManager( + this.userCredentials, this.userEntities); + Bytes credentialId = TestCredentialRecords.userCredential().build().getCredentialId(); + given(this.userCredentials.findByCredentialId(credentialId)) + .willReturn(TestCredentialRecords.userCredential().build()); + TestingAuthenticationToken authentication = new TestingAuthenticationToken("user", "password", "USER"); + AuthorizationDecision decision = manager.check(() -> authentication, credentialId); + assertThat(decision.isGranted()).isFalse(); + } + + @Test + void checkWhenCredentialBelongsToUserThenGranted() { + CredentialRecordOwnerAuthorizationManager manager = new CredentialRecordOwnerAuthorizationManager( + this.userCredentials, this.userEntities); + Bytes credentialId = TestCredentialRecords.userCredential().build().getCredentialId(); + Bytes userId = TestCredentialRecords.userCredential().build().getUserEntityUserId(); + given(this.userCredentials.findByCredentialId(credentialId)) + .willReturn(TestCredentialRecords.userCredential().build()); + PublicKeyCredentialUserEntity userEntity = ImmutablePublicKeyCredentialUserEntity.builder() + .name("user") + .id(userId) + .displayName("User") + .build(); + given(this.userEntities.findByUsername("user")).willReturn(userEntity); + TestingAuthenticationToken authentication = new TestingAuthenticationToken("user", "password", "USER"); + AuthorizationDecision decision = manager.check(() -> authentication, credentialId); + assertThat(decision.isGranted()).isTrue(); + } + + @Test + void checkWhenCredentialBelongsToDifferentUserThenDenied() { + CredentialRecordOwnerAuthorizationManager manager = new CredentialRecordOwnerAuthorizationManager( + this.userCredentials, this.userEntities); + Bytes credentialId = TestCredentialRecords.userCredential().build().getCredentialId(); + given(this.userCredentials.findByCredentialId(credentialId)) + .willReturn(TestCredentialRecords.userCredential().build()); + PublicKeyCredentialUserEntity otherUserEntity = ImmutablePublicKeyCredentialUserEntity.builder() + .name("user") + .id(TestBytes.get()) + .displayName("User") + .build(); + given(this.userEntities.findByUsername("user")).willReturn(otherUserEntity); + TestingAuthenticationToken authentication = new TestingAuthenticationToken("user", "password", "USER"); + AuthorizationDecision decision = manager.check(() -> authentication, credentialId); + assertThat(decision.isGranted()).isFalse(); + } + +} diff --git a/web/src/test/java/org/springframework/security/web/webauthn/registration/WebAuthnRegistrationFilterTests.java b/web/src/test/java/org/springframework/security/web/webauthn/registration/WebAuthnRegistrationFilterTests.java index 82783c204e..cad02586d0 100644 --- a/web/src/test/java/org/springframework/security/web/webauthn/registration/WebAuthnRegistrationFilterTests.java +++ b/web/src/test/java/org/springframework/security/web/webauthn/registration/WebAuthnRegistrationFilterTests.java @@ -31,7 +31,11 @@ import org.springframework.mock.web.MockFilterChain; import org.springframework.mock.web.MockHttpServletRequest; import org.springframework.mock.web.MockHttpServletResponse; import org.springframework.mock.web.MockServletContext; +import org.springframework.security.authorization.AuthorizationDecision; +import org.springframework.security.authorization.AuthorizationManager; +import org.springframework.security.authorization.SingleResultAuthorizationManager; import org.springframework.security.web.util.matcher.RequestMatcher; +import org.springframework.security.web.webauthn.api.Bytes; import org.springframework.security.web.webauthn.api.ImmutableCredentialRecord; import org.springframework.security.web.webauthn.api.PublicKeyCredentialCreationOptions; import org.springframework.security.web.webauthn.api.TestCredentialRecords; @@ -213,12 +217,41 @@ class WebAuthnRegistrationFilterTests { @Test void doFilterWhenDeleteSuccessThenNoContent() throws Exception { + this.filter.setDeleteCredentialAuthorizationManager(SingleResultAuthorizationManager.permitAll()); MockHttpServletRequest request = MockMvcRequestBuilders.delete("/webauthn/register/123456") .buildRequest(new MockServletContext()); this.filter.doFilter(request, this.response, this.chain); assertThat(this.response.getStatus()).isEqualTo(HttpStatus.NO_CONTENT.value()); } + @Test + void setDeleteCredentialAuthorizationManagerWhenNullThenIllegalArgument() { + assertThatIllegalArgumentException() + .isThrownBy(() -> this.filter.setDeleteCredentialAuthorizationManager(null)); + } + + @Test + void doFilterWhenDeleteAndCustomAuthorizationManagerThenUses() throws Exception { + AuthorizationManager authorizationManager = mock(AuthorizationManager.class); + given(authorizationManager.authorize(any(), any())).willReturn(new AuthorizationDecision(true)); + this.filter.setDeleteCredentialAuthorizationManager(authorizationManager); + MockHttpServletRequest request = MockMvcRequestBuilders.delete("/webauthn/register/123456") + .buildRequest(new MockServletContext()); + this.filter.doFilter(request, this.response, this.chain); + verify(authorizationManager).authorize(any(), any()); + } + + @Test + void doFilterWhenDeleteAndAuthorizationDeniedThenForbidden() throws Exception { + AuthorizationManager authorizationManager = mock(AuthorizationManager.class); + given(authorizationManager.authorize(any(), any())).willReturn(new AuthorizationDecision(false)); + this.filter.setDeleteCredentialAuthorizationManager(authorizationManager); + MockHttpServletRequest request = MockMvcRequestBuilders.delete("/webauthn/register/123456") + .buildRequest(new MockServletContext()); + this.filter.doFilter(request, this.response, this.chain); + assertThat(this.response.getStatus()).isEqualTo(HttpStatus.FORBIDDEN.value()); + } + private static MockHttpServletRequest registerCredentialRequest(String body) { return MockMvcRequestBuilders.post(WebAuthnRegistrationFilter.DEFAULT_REGISTER_CREDENTIAL_URL) .content(body)