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)