Add Passkeys Support

Closes gh-13305
This commit is contained in:
Rob Winch
2024-10-17 21:40:51 -05:00
parent f280aa390b
commit b0e8730d70
157 changed files with 19203 additions and 5 deletions

View File

@@ -0,0 +1,109 @@
/*
* Copyright 2002-2024 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.authentication;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.BDDMockito;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.skyscreamer.jsonassert.JSONAssert;
import org.springframework.http.converter.HttpMessageConverter;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockHttpServletResponse;
import org.springframework.security.authentication.TestingAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.web.savedrequest.RequestCache;
import org.springframework.security.web.savedrequest.SimpleSavedRequest;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.BDDMockito.given;
import static org.mockito.BDDMockito.verify;
@ExtendWith(MockitoExtension.class)
class HttpMessageConverterAuthenticationSuccessHandlerTests {
@Mock
private HttpMessageConverter converter;
@Mock
private RequestCache requestCache;
private HttpMessageConverterAuthenticationSuccessHandler handler = new HttpMessageConverterAuthenticationSuccessHandler();
private MockHttpServletRequest request = new MockHttpServletRequest();
private MockHttpServletResponse response = new MockHttpServletResponse();
private Authentication authentication = new TestingAuthenticationToken("user", "password", "ROLE_USER");
@Test
void setConverterWhenNullThenThrowIllegalArgumentException() {
assertThatIllegalArgumentException().isThrownBy(() -> this.handler.setConverter(null));
}
@Test
void setRequestCacheWhenNullThenThrowIllegalArgumentException() {
assertThatIllegalArgumentException().isThrownBy(() -> this.handler.setRequestCache(null));
}
@Test
void onAuthenticationSuccessWhenDefaultsThenContextRoot() throws Exception {
this.handler.onAuthenticationSuccess(this.request, this.response, this.authentication);
String body = this.response.getContentAsString();
JSONAssert.assertEquals("""
{
"redirectUrl" : "/",
"authenticated": true
}""", body, false);
}
@Test
void onAuthenticationSuccessWhenSavedRequestThenInResponse() throws Exception {
SimpleSavedRequest savedRequest = new SimpleSavedRequest("/redirect");
given(this.requestCache.getRequest(this.request, this.response)).willReturn(savedRequest);
this.handler.setRequestCache(this.requestCache);
this.handler.onAuthenticationSuccess(this.request, this.response, this.authentication);
verify(this.requestCache).removeRequest(this.request, this.response);
String body = this.response.getContentAsString();
JSONAssert.assertEquals("""
{
"redirectUrl" : "/redirect",
"authenticated": true
}""", body, false);
}
@Test
void onAuthenticationSuccessWhenCustomConverterThenInResponse() throws Exception {
SimpleSavedRequest savedRequest = new SimpleSavedRequest("/redirect");
given(this.requestCache.getRequest(this.request, this.response)).willReturn(savedRequest);
String expectedBody = "Custom!";
BDDMockito.doAnswer((invocation) -> {
this.response.getWriter().write(expectedBody);
return null;
}).when(this.converter).write(any(), any(), any());
this.handler.setRequestCache(this.requestCache);
this.handler.setConverter(this.converter);
this.handler.onAuthenticationSuccess(this.request, this.response, this.authentication);
String body = this.response.getContentAsString();
assertThat(body).isEqualTo(expectedBody);
}
}

View File

@@ -0,0 +1,48 @@
/*
* Copyright 2002-2024 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.lang.reflect.Field;
import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class COSEAlgorithmIdentifierTests {
@Test
void valuesContainsAll() {
List<COSEAlgorithmIdentifier> allMembers = Arrays.stream(COSEAlgorithmIdentifier.class.getFields())
.filter((f) -> f.getType().isAssignableFrom(COSEAlgorithmIdentifier.class))
.map((f) -> (COSEAlgorithmIdentifier) getValue(f))
.collect(Collectors.toUnmodifiableList());
assertThat(COSEAlgorithmIdentifier.values()).containsExactlyInAnyOrderElementsOf(allMembers);
}
private <T> T getValue(Field f) {
try {
return (T) f.get(null);
}
catch (IllegalAccessException ex) {
throw new RuntimeException(ex);
}
}
}

View File

@@ -0,0 +1,33 @@
/*
* Copyright 2002-2024 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;
public final class TestAuthenticatorAttestationResponse {
public static AuthenticatorAttestationResponse.AuthenticatorAttestationResponseBuilder createAuthenticatorAttestationResponse() {
return AuthenticatorAttestationResponse.builder()
.attestationObject(Bytes.fromBase64(
"o2NmbXRkbm9uZWdhdHRTdG10oGhhdXRoRGF0YViUy9GqwTRaMpzVDbXq1dyEAXVOxrou08k22ggRC45MKNhdAAAAALraVWanqkAfvZZFYZpVEg0AEDWRLOHq0Wxw4cOkCemynKqlAQIDJiABIVgg4Hkrn2kbGmpZTdoDZUNrppo93OqgQV7ONzVvo5GLCFciWCCrf6yIQggq2BfZntawxRsBBbWG_FWkYAoU8yPipS-5hg-p1VREax-qKTGn1Bp92fRMjRMunH7XwSlf9mMWJdY3VvAkicTChlxVd256yk4jEiXiJ5BuwugdpT7fBOYtymjpQECAyYgASFYIJK-2epPEw0ujHN-gvVp2Hp3ef8CzU3zqwO5ylx8L2OsIlggK5x5OlTGEPxLS-85TAABum4aqVK4CSWJ7LYDdkjuBLk"))
.clientDataJSON(Bytes.fromBase64(
"eyJ0eXBlIjoid2ViYXV0aG4uY3JlYXRlIiwiY2hhbGxlbmdlIjoicTdsQ2RkM1NWUXhkQy12OHBuUkFHRW4xQjJNLXQ3WkVDV1B3Q0FtaFd2YyIsIm9yaWdpbiI6Imh0dHBzOi8vZXhhbXBsZS5sb2NhbGhvc3Q6ODQ0MyIsImNyb3NzT3JpZ2luIjpmYWxzZX0"))
.transports(AuthenticatorTransport.HYBRID, AuthenticatorTransport.INTERNAL);
}
private TestAuthenticatorAttestationResponse() {
}
}

View File

@@ -0,0 +1,35 @@
/*
* Copyright 2002-2024 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;
public final class TestCredentialRecord {
public static ImmutableCredentialRecord.ImmutableCredentialRecordBuilder userCredential() {
return ImmutableCredentialRecord.builder()
.label("label")
.credentialId(Bytes.fromBase64("NauGCN7bZ5jEBwThcde51g"))
.userEntityUserId(Bytes.fromBase64("vKBFhsWT3gQnn-gHdT4VXIvjDkVXVYg5w8CLGHPunMM"))
.publicKey(ImmutablePublicKeyCose.fromBase64(
"pQECAyYgASFYIC7DAiV_trHFPjieOxXbec7q2taBcgLnIi19zrUwVhCdIlggvN6riHORK_velHcTLFK_uJhyKK0oBkJqzNqR2E-2xf8="))
.backupEligible(true)
.backupState(true);
}
private TestCredentialRecord() {
}
}

View File

@@ -0,0 +1,44 @@
/*
* Copyright 2002-2024 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;
public final class TestPublicKeyCredential {
public static PublicKeyCredential.PublicKeyCredentialBuilder<AuthenticatorAttestationResponse> createPublicKeyCredential() {
AuthenticatorAttestationResponse response = TestAuthenticatorAttestationResponse
.createAuthenticatorAttestationResponse()
.build();
return createPublicKeyCredential(response);
}
public static <R extends AuthenticatorResponse> PublicKeyCredential.PublicKeyCredentialBuilder<R> createPublicKeyCredential(
R response) {
ImmutableAuthenticationExtensionsClientOutputs clientExtensionResults = new ImmutableAuthenticationExtensionsClientOutputs(
new CredentialPropertiesOutput(false));
return PublicKeyCredential.builder()
.id("AX6nVVERrH6opMafUGn3Z9EyNEy6cftfBKV_2YxYl1jdW8CSJxMKGXFV3bnrKTiMSJeInkG7C6B2lPt8E5i3KaM")
.rawId(Bytes
.fromBase64("AX6nVVERrH6opMafUGn3Z9EyNEy6cftfBKV_2YxYl1jdW8CSJxMKGXFV3bnrKTiMSJeInkG7C6B2lPt8E5i3KaM"))
.response(response)
.type(PublicKeyCredentialType.PUBLIC_KEY)
.clientExtensionResults(clientExtensionResults);
}
private TestPublicKeyCredential() {
}
}

View File

@@ -0,0 +1,57 @@
/*
* Copyright 2002-2024 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.time.Duration;
public final class TestPublicKeyCredentialCreationOptions {
public static PublicKeyCredentialCreationOptions.PublicKeyCredentialCreationOptionsBuilder createPublicKeyCredentialCreationOptions() {
AuthenticatorSelectionCriteria authenticatorSelection = AuthenticatorSelectionCriteria.builder()
.userVerification(UserVerificationRequirement.PREFERRED)
.residentKey(ResidentKeyRequirement.REQUIRED)
.build();
Bytes challenge = Bytes.fromBase64("q7lCdd3SVQxdC-v8pnRAGEn1B2M-t7ZECWPwCAmhWvc");
PublicKeyCredentialRpEntity rp = PublicKeyCredentialRpEntity.builder()
.id("example.localhost")
.name("SimpleWebAuthn Example")
.build();
Bytes userId = Bytes.fromBase64("oWJtkJ6vJ_m5b84LB4_K7QKTCTEwLIjCh4tFMCGHO4w");
PublicKeyCredentialUserEntity userEntity = ImmutablePublicKeyCredentialUserEntity.builder()
.displayName("user@example.localhost")
.id(userId)
.name("user@example.localhost")
.build();
ImmutableAuthenticationExtensionsClientInputs clientInputs = new ImmutableAuthenticationExtensionsClientInputs(
ImmutableAuthenticationExtensionsClientInput.credProps);
return PublicKeyCredentialCreationOptions.builder()
.attestation(AttestationConveyancePreference.DIRECT)
.user(userEntity)
.pubKeyCredParams(PublicKeyCredentialParameters.EdDSA, PublicKeyCredentialParameters.ES256,
PublicKeyCredentialParameters.RS256)
.authenticatorSelection(authenticatorSelection)
.challenge(challenge)
.rp(rp)
.extensions(clientInputs)
.timeout(Duration.ofMinutes(5));
}
private TestPublicKeyCredentialCreationOptions() {
}
}

View File

@@ -0,0 +1,34 @@
/*
* Copyright 2002-2024 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.time.Duration;
public final class TestPublicKeyCredentialRequestOptions {
public static PublicKeyCredentialRequestOptions.PublicKeyCredentialRequestOptionsBuilder create() {
return PublicKeyCredentialRequestOptions.builder()
.timeout(Duration.ofMinutes(5))
.rpId("example.localhost")
.userVerification(UserVerificationRequirement.PREFERRED)
.challenge(Bytes.fromBase64("cQfdGrj9zDg3zNBkOH3WPL954FTOShVy0-CoNgSewNM"));
}
private TestPublicKeyCredentialRequestOptions() {
}
}

View File

@@ -0,0 +1,30 @@
/*
* Copyright 2002-2024 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.springframework.security.web.webauthn.api.ImmutablePublicKeyCredentialUserEntity.PublicKeyCredentialUserEntityBuilder;
public final class TestPublicKeyCredentialUserEntity {
public static PublicKeyCredentialUserEntityBuilder userEntity() {
return ImmutablePublicKeyCredentialUserEntity.builder().name("user").id(Bytes.random()).displayName("user");
}
private TestPublicKeyCredentialUserEntity() {
}
}

View File

@@ -0,0 +1,89 @@
/*
* Copyright 2002-2024 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.authentication;
import org.junit.jupiter.api.Test;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockHttpServletResponse;
import org.springframework.security.web.webauthn.api.PublicKeyCredentialRequestOptions;
import org.springframework.security.web.webauthn.api.TestPublicKeyCredentialRequestOptions;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Tests for {@link HttpSessionPublicKeyCredentialRequestOptionsRepository}.
*
* @author Rob Winch
* @since 6.4
*/
class HttpSessionPublicKeyCredentialRequestOptionsRepositoryTests {
private HttpSessionPublicKeyCredentialRequestOptionsRepository repository = new HttpSessionPublicKeyCredentialRequestOptionsRepository();
private MockHttpServletRequest request = new MockHttpServletRequest();
private MockHttpServletResponse response = new MockHttpServletResponse();
@Test
void integrationTests() {
PublicKeyCredentialRequestOptions expected = TestPublicKeyCredentialRequestOptions.create().build();
this.repository.save(this.request, this.response, expected);
Object attrValue = this.request.getSession()
.getAttribute(HttpSessionPublicKeyCredentialRequestOptionsRepository.DEFAULT_ATTR_NAME);
assertThat(attrValue).isEqualTo(expected);
PublicKeyCredentialRequestOptions loadOptions = this.repository.load(this.request);
assertThat(loadOptions).isEqualTo(expected);
this.repository.save(this.request, this.response, null);
Object attrValueAfterRemoval = this.request.getSession()
.getAttribute(HttpSessionPublicKeyCredentialRequestOptionsRepository.DEFAULT_ATTR_NAME);
assertThat(attrValueAfterRemoval).isNull();
assertThat(this.request.getSession().getAttributeNames())
.doesNotHaveToString(HttpSessionPublicKeyCredentialRequestOptionsRepository.DEFAULT_ATTR_NAME);
PublicKeyCredentialRequestOptions loadOptionsAfterRemoval = this.repository.load(this.request);
assertThat(loadOptionsAfterRemoval).isNull();
}
@Test
void loadWhenNullSessionThenDoesNotCreate() {
PublicKeyCredentialRequestOptions options = this.repository.load(this.request);
assertThat(options).isNull();
assertThat(this.request.getSession(false)).isNull();
}
@Test
void saveWhenSetAttrThenCustomAttr() {
PublicKeyCredentialRequestOptions expected = TestPublicKeyCredentialRequestOptions.create().build();
String customAttr = "custom-attr";
this.repository.setAttrName(customAttr);
this.repository.save(this.request, this.response, expected);
assertThat(this.request.getSession().getAttribute(customAttr)).isEqualTo(expected);
}
@Test
void loadWhenSetAttrThenCustomAttr() {
PublicKeyCredentialRequestOptions expected = TestPublicKeyCredentialRequestOptions.create().build();
String customAttr = "custom-attr";
this.repository.setAttrName(customAttr);
this.request.getSession().setAttribute(customAttr, expected);
PublicKeyCredentialRequestOptions actual = this.repository.load(this.request);
assertThat(actual).isEqualTo(expected);
}
}

View File

@@ -0,0 +1,170 @@
/*
* Copyright 2002-2024 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.authentication;
import java.nio.charset.StandardCharsets;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.invocation.InvocationOnMock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.mockito.stubbing.Answer;
import org.skyscreamer.jsonassert.JSONAssert;
import org.springframework.http.converter.HttpMessageConverter;
import org.springframework.http.server.ServletServerHttpResponse;
import org.springframework.security.authentication.TestingAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.context.SecurityContextHolderStrategy;
import org.springframework.security.core.context.SecurityContextImpl;
import org.springframework.security.web.webauthn.api.PublicKeyCredentialCreationOptions;
import org.springframework.security.web.webauthn.api.PublicKeyCredentialRequestOptions;
import org.springframework.security.web.webauthn.api.TestPublicKeyCredentialRequestOptions;
import org.springframework.security.web.webauthn.management.WebAuthnRelyingPartyOperations;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.BDDMockito.given;
import static org.mockito.BDDMockito.verifyNoInteractions;
import static org.mockito.BDDMockito.willAnswer;
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.status;
/**
* Tests for {@link PublicKeyCredentialRequestOptionsFilter}.
*
* @author Rob Winch
* @since 6.4
*/
@ExtendWith(MockitoExtension.class)
class PublicKeyCredentialRequestOptionsFilterTests {
@Mock
private WebAuthnRelyingPartyOperations relyingPartyOperations;
@Mock
private PublicKeyCredentialRequestOptionsRepository requestOptionsRepository;
@Mock
private HttpMessageConverter<Object> converter;
@Mock
private SecurityContextHolderStrategy contextHolderStrategy;
private PublicKeyCredentialRequestOptionsFilter filter;
private MockMvc mockMvc;
@BeforeEach
void setup() {
this.filter = new PublicKeyCredentialRequestOptionsFilter(this.relyingPartyOperations);
this.filter.setRequestOptionsRepository(this.requestOptionsRepository);
this.mockMvc = MockMvcBuilders.standaloneSetup().addFilter(this.filter).build();
}
@AfterEach
void cleanup() {
SecurityContextHolder.clearContext();
}
@Test
void constructorWhenNull() {
assertThatExceptionOfType(IllegalArgumentException.class)
.isThrownBy(() -> new PublicKeyCredentialRequestOptionsFilter(null));
}
@Test
void doFilterWhenNoMatch() throws Exception {
this.mockMvc.perform(post("/nomatch"))
.andExpect(status().isNotFound())
.andDo((result) -> assertThat(result.getResponse().getContentAsString()).isEmpty());
verifyNoInteractions(this.relyingPartyOperations, this.requestOptionsRepository);
}
@Test
void doFilterWhenNotPost() throws Exception {
this.mockMvc.perform(get("/webauthn/authenticate/options"))
.andExpect(status().isNotFound())
.andDo((result) -> assertThat(result.getResponse().getContentAsString()).isEmpty());
verifyNoInteractions(this.relyingPartyOperations, this.requestOptionsRepository);
}
@Test
void doFilterWhenMatches() throws Exception {
PublicKeyCredentialRequestOptions options = TestPublicKeyCredentialRequestOptions.create().build();
given(this.relyingPartyOperations.createCredentialRequestOptions(any())).willReturn(options);
PublicKeyCredentialCreationOptions mockResult = this.relyingPartyOperations
.createPublicKeyCredentialCreationOptions(null);
this.mockMvc.perform(post("/webauthn/authenticate/options"))
.andExpect(status().isOk())
.andDo((result) -> JSONAssert.assertEquals(result.getResponse().getContentAsString(), """
{
"challenge": "cQfdGrj9zDg3zNBkOH3WPL954FTOShVy0-CoNgSewNM",
"timeout": 300000,
"rpId": "example.localhost",
"allowCredentials": [],
"userVerification": "preferred",
"extensions": {}
}
""", false));
}
@Test
void doFilterWhenCustom() throws Exception {
String body = "custom body";
willAnswer(new Answer<Void>() {
@Override
public Void answer(InvocationOnMock invocation) throws Throwable {
ServletServerHttpResponse response = invocation.getArgument(2);
response.getBody().write(body.getBytes(StandardCharsets.UTF_8));
return null;
}
}).given(this.converter).write(any(), any(), any());
given(this.contextHolderStrategy.getContext())
.willReturn(new SecurityContextImpl(new TestingAuthenticationToken("user", "password", "ROLE_USER")));
this.filter.setConverter(this.converter);
this.filter.setSecurityContextHolderStrategy(this.contextHolderStrategy);
PublicKeyCredentialRequestOptions options = TestPublicKeyCredentialRequestOptions.create().build();
given(this.relyingPartyOperations.createCredentialRequestOptions(any())).willReturn(options);
PublicKeyCredentialCreationOptions mockResult = this.relyingPartyOperations
.createPublicKeyCredentialCreationOptions(null);
this.mockMvc.perform(post("/webauthn/authenticate/options"))
.andExpect(status().isOk())
.andDo((result) -> assertThat(result.getResponse().getContentAsString()).isEqualTo(body));
}
@Test
void setConverterWhenNull() {
assertThatIllegalArgumentException().isThrownBy(() -> this.filter.setConverter(null));
}
@Test
void setSecurityContextHolderStrategyWhenNull() {
assertThatIllegalArgumentException().isThrownBy(() -> this.filter.setSecurityContextHolderStrategy(null));
}
}

View File

@@ -0,0 +1,197 @@
/*
* Copyright 2002-2024 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.authentication;
import jakarta.servlet.FilterChain;
import jakarta.servlet.http.HttpServletResponse;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.skyscreamer.jsonassert.JSONAssert;
import org.springframework.http.HttpStatus;
import org.springframework.http.converter.GenericHttpMessageConverter;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockHttpServletResponse;
import org.springframework.mock.web.MockServletContext;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.core.authority.AuthorityUtils;
import org.springframework.security.web.webauthn.api.AuthenticatorAssertionResponse;
import org.springframework.security.web.webauthn.api.AuthenticatorAttachment;
import org.springframework.security.web.webauthn.api.PublicKeyCredential;
import org.springframework.security.web.webauthn.api.PublicKeyCredentialRequestOptions;
import org.springframework.security.web.webauthn.api.PublicKeyCredentialUserEntity;
import org.springframework.security.web.webauthn.api.TestPublicKeyCredentialRequestOptions;
import org.springframework.security.web.webauthn.api.TestPublicKeyCredentialUserEntity;
import org.springframework.security.web.webauthn.management.RelyingPartyAuthenticationRequest;
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.isNull;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
/**
* Tests for {@link WebAuthnAuthenticationFilter}.
*
* @author Rob Winch
* @since 6.4
*/
@ExtendWith(MockitoExtension.class)
class WebAuthnAuthenticationFilterTests {
private static final String VALID_BODY = """
{
"id": "dYF7EGnRFFIXkpXi9XU2wg",
"rawId": "dYF7EGnRFFIXkpXi9XU2wg",
"response": {
"authenticatorData": "y9GqwTRaMpzVDbXq1dyEAXVOxrou08k22ggRC45MKNgdAAAAAA",
"clientDataJSON": "eyJ0eXBlIjoid2ViYXV0aG4uZ2V0IiwiY2hhbGxlbmdlIjoiRFVsRzRDbU9naWhKMG1vdXZFcE9HdUk0ZVJ6MGRRWmxUQmFtbjdHQ1FTNCIsIm9yaWdpbiI6Imh0dHBzOi8vZXhhbXBsZS5sb2NhbGhvc3Q6ODQ0MyIsImNyb3NzT3JpZ2luIjpmYWxzZX0",
"signature": "MEYCIQCW2BcUkRCAXDmGxwMi78jknenZ7_amWrUJEYoTkweldAIhAMD0EMp1rw2GfwhdrsFIeDsL7tfOXVPwOtfqJntjAo4z",
"userHandle": "Q3_0Xd64_HW0BlKRAJnVagJTpLKLgARCj8zjugpRnVo"
},
"clientExtensionResults": {},
"authenticatorAttachment": "platform"
}
""";
@Mock
private GenericHttpMessageConverter<Object> converter;
@Mock
private PublicKeyCredentialRequestOptionsRepository requestOptionsRepository;
@Mock
private AuthenticationManager authenticationManager;
private MockHttpServletResponse response = new MockHttpServletResponse();
@Mock
private FilterChain chain;
private WebAuthnAuthenticationFilter filter = new WebAuthnAuthenticationFilter();
@BeforeEach
void setup() {
this.filter.setAuthenticationManager(this.authenticationManager);
}
@Test
void setConverterWhenNullThenIllegalArgumentException() {
assertThatIllegalArgumentException().isThrownBy(() -> this.filter.setConverter(null));
}
@Test
void setRequestOptionsRepositoryWhenNullThenIllegalArgumentException() {
assertThatIllegalArgumentException().isThrownBy(() -> this.filter.setRequestOptionsRepository(null));
}
@Test
void doFilterWhenUrlDoesNotMatchThenChainContinues() throws Exception {
HttpServletResponse response = mock(HttpServletResponse.class);
this.filter.setConverter(this.converter);
this.filter.setRequestOptionsRepository(this.requestOptionsRepository);
this.filter.doFilter(post("/nomatch").buildRequest(new MockServletContext()), response, this.chain);
verifyNoInteractions(this.converter, this.requestOptionsRepository, response);
verify(this.chain).doFilter(any(), any());
}
@Test
void doFilterWhenMethodDoesNotMatchThenChainContinues() throws Exception {
HttpServletResponse response = mock(HttpServletResponse.class);
this.filter.setConverter(this.converter);
this.filter.setRequestOptionsRepository(this.requestOptionsRepository);
this.filter.doFilter(get("/login/webauthn").buildRequest(new MockServletContext()), response, this.chain);
verifyNoInteractions(this.converter, this.requestOptionsRepository, response);
verify(this.chain).doFilter(any(), any());
}
@Test
void doFilterWhenNoBodyThenUnauthorized() throws Exception {
this.filter.doFilter(matchingRequest(""), this.response, this.chain);
assertThat(this.response.getStatus()).isEqualTo(HttpStatus.UNAUTHORIZED.value());
}
@Test
void doFilterWhenInvalidJsonThenUnauthorized() throws Exception {
MockHttpServletRequest request = matchingRequest("<>");
this.filter.doFilter(request, this.response, this.chain);
assertThat(this.response.getStatus()).isEqualTo(HttpStatus.UNAUTHORIZED.value());
}
@Test
void doFilterWhenOptionsNullThenUnAuthorized() throws Exception {
MockHttpServletRequest request = matchingRequest(VALID_BODY);
this.filter.doFilter(request, this.response, this.chain);
assertThat(this.response.getStatus()).isEqualTo(HttpStatus.UNAUTHORIZED.value());
}
@Test
void doFilterWhenValidThenOk() throws Exception {
PublicKeyCredentialRequestOptions options = TestPublicKeyCredentialRequestOptions.create().build();
given(this.requestOptionsRepository.load(any())).willReturn(options);
PublicKeyCredentialUserEntity principal = TestPublicKeyCredentialUserEntity.userEntity().build();
WebAuthnAuthentication authentication = new WebAuthnAuthentication(principal,
AuthorityUtils.createAuthorityList("ROLE_USER"));
given(this.authenticationManager.authenticate(any())).willReturn(authentication);
this.filter.setRequestOptionsRepository(this.requestOptionsRepository);
MockHttpServletRequest request = matchingRequest(VALID_BODY);
this.filter.doFilter(request, this.response, this.chain);
verify(this.requestOptionsRepository).save(any(), any(), isNull());
ArgumentCaptor<WebAuthnAuthenticationRequestToken> authenticationCaptor = ArgumentCaptor
.forClass(WebAuthnAuthenticationRequestToken.class);
verify(this.authenticationManager).authenticate(authenticationCaptor.capture());
assertThat(this.response.getStatus()).isEqualTo(HttpStatus.OK.value());
WebAuthnAuthenticationRequestToken token = authenticationCaptor.getValue();
assertThat(token).isNotNull();
RelyingPartyAuthenticationRequest authnRequest = token.getWebAuthnRequest();
PublicKeyCredential<AuthenticatorAssertionResponse> publicKey = authnRequest.getPublicKey();
AuthenticatorAssertionResponse assertionResponse = publicKey.getResponse();
assertThat(publicKey.getId()).isEqualTo("dYF7EGnRFFIXkpXi9XU2wg");
assertThat(publicKey.getRawId().toBase64UrlString()).isEqualTo("dYF7EGnRFFIXkpXi9XU2wg");
assertThat(assertionResponse.getAuthenticatorData().toBase64UrlString())
.isEqualTo("y9GqwTRaMpzVDbXq1dyEAXVOxrou08k22ggRC45MKNgdAAAAAA");
assertThat(assertionResponse.getClientDataJSON().toBase64UrlString()).isEqualTo(
"eyJ0eXBlIjoid2ViYXV0aG4uZ2V0IiwiY2hhbGxlbmdlIjoiRFVsRzRDbU9naWhKMG1vdXZFcE9HdUk0ZVJ6MGRRWmxUQmFtbjdHQ1FTNCIsIm9yaWdpbiI6Imh0dHBzOi8vZXhhbXBsZS5sb2NhbGhvc3Q6ODQ0MyIsImNyb3NzT3JpZ2luIjpmYWxzZX0");
assertThat(assertionResponse.getSignature().toBase64UrlString()).isEqualTo(
"MEYCIQCW2BcUkRCAXDmGxwMi78jknenZ7_amWrUJEYoTkweldAIhAMD0EMp1rw2GfwhdrsFIeDsL7tfOXVPwOtfqJntjAo4z");
assertThat(assertionResponse.getUserHandle().toBase64UrlString())
.isEqualTo("Q3_0Xd64_HW0BlKRAJnVagJTpLKLgARCj8zjugpRnVo");
assertThat(publicKey.getClientExtensionResults().getOutputs()).isEmpty();
assertThat(authnRequest.getRequestOptions()).isEqualTo(options);
assertThat(authnRequest.getPublicKey().getAuthenticatorAttachment())
.isEqualTo(AuthenticatorAttachment.PLATFORM);
String expectedBody = """
{"redirectUrl":"/","authenticated":true}
""";
JSONAssert.assertEquals(expectedBody, this.response.getContentAsString(), false);
}
private static MockHttpServletRequest matchingRequest(String body) {
return MockMvcRequestBuilders.post("/login/webauthn").content(body).buildRequest(new MockServletContext());
}
}

View File

@@ -0,0 +1,58 @@
/*
* Copyright 2002-2024 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.authentication;
import java.util.List;
import org.junit.jupiter.api.Test;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.AuthorityUtils;
import org.springframework.security.web.webauthn.api.PublicKeyCredentialUserEntity;
import org.springframework.security.web.webauthn.api.TestPublicKeyCredentialUserEntity;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
class WebAuthnAuthenticationTests {
@Test
void isAuthenticatedThenTrue() {
PublicKeyCredentialUserEntity userEntity = TestPublicKeyCredentialUserEntity.userEntity().build();
List<GrantedAuthority> authorities = AuthorityUtils.createAuthorityList("ROLE_USER");
WebAuthnAuthentication authentication = new WebAuthnAuthentication(userEntity, authorities);
assertThat(authentication.isAuthenticated()).isTrue();
}
@Test
void setAuthenticationWhenTrueThenException() {
PublicKeyCredentialUserEntity userEntity = TestPublicKeyCredentialUserEntity.userEntity().build();
List<GrantedAuthority> authorities = AuthorityUtils.createAuthorityList("ROLE_USER");
WebAuthnAuthentication authentication = new WebAuthnAuthentication(userEntity, authorities);
assertThatIllegalArgumentException().isThrownBy(() -> authentication.setAuthenticated(true));
}
@Test
void setAuthenticationWhenFalseThenNotAuthenticated() {
PublicKeyCredentialUserEntity userEntity = TestPublicKeyCredentialUserEntity.userEntity().build();
List<GrantedAuthority> authorities = AuthorityUtils.createAuthorityList("ROLE_USER");
WebAuthnAuthentication authentication = new WebAuthnAuthentication(userEntity, authorities);
authentication.setAuthenticated(false);
assertThat(authentication.isAuthenticated()).isFalse();
}
}

View File

@@ -0,0 +1,153 @@
/*
* Copyright 2002-2024 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.jackson;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.skyscreamer.jsonassert.JSONAssert;
import org.springframework.security.web.webauthn.api.CredProtectAuthenticationExtensionsClientInput;
import org.springframework.security.web.webauthn.api.ImmutableAuthenticationExtensionsClientInputs;
/**
* Test Jackson serialization of CredProtectAuthenticationExtensionsClientInput
*
* @author Rob Winch
*/
class CredProtectAuthenticationExtensionsClientInputJacksonTests {
private ObjectMapper mapper;
@BeforeEach
void setup() {
this.mapper = new ObjectMapper();
this.mapper.registerModule(new WebauthnJackson2Module());
}
@Test
void writeAuthenticationExtensionsClientInputsWhenCredProtectUserVerificationOptional() throws Exception {
String expected = """
{
"credentialProtectionPolicy": "userVerificationOptional",
"enforceCredentialProtectionPolicy": true
}
""";
CredProtectAuthenticationExtensionsClientInput.CredProtect credProtect = new CredProtectAuthenticationExtensionsClientInput.CredProtect(
CredProtectAuthenticationExtensionsClientInput.CredProtect.ProtectionPolicy.USER_VERIFICATION_OPTIONAL,
true);
CredProtectAuthenticationExtensionsClientInput credProtectInput = new CredProtectAuthenticationExtensionsClientInput(
credProtect);
ImmutableAuthenticationExtensionsClientInputs clientInputs = new ImmutableAuthenticationExtensionsClientInputs(
credProtectInput);
String actual = this.mapper.writeValueAsString(clientInputs);
JSONAssert.assertEquals(expected, actual, false);
}
@Test
void writeAuthenticationExtensionsClientInputsWhenCredProtectUserVerificationOptionalWithCredentialIdList()
throws Exception {
String expected = """
{
"credentialProtectionPolicy": "userVerificationOptionalWithCredentialIdList",
"enforceCredentialProtectionPolicy": true
}
""";
CredProtectAuthenticationExtensionsClientInput.CredProtect credProtect = new CredProtectAuthenticationExtensionsClientInput.CredProtect(
CredProtectAuthenticationExtensionsClientInput.CredProtect.ProtectionPolicy.USER_VERIFICATION_OPTIONAL_WITH_CREDENTIAL_ID_LIST,
true);
CredProtectAuthenticationExtensionsClientInput credProtectInput = new CredProtectAuthenticationExtensionsClientInput(
credProtect);
ImmutableAuthenticationExtensionsClientInputs clientInputs = new ImmutableAuthenticationExtensionsClientInputs(
credProtectInput);
String actual = this.mapper.writeValueAsString(clientInputs);
JSONAssert.assertEquals(expected, actual, false);
}
@Test
void writeAuthenticationExtensionsClientInputsWhenCredProtectUserVerificationRequired() throws Exception {
String expected = """
{
"credentialProtectionPolicy": "userVerificationRequired",
"enforceCredentialProtectionPolicy": true
}
""";
CredProtectAuthenticationExtensionsClientInput.CredProtect credProtect = new CredProtectAuthenticationExtensionsClientInput.CredProtect(
CredProtectAuthenticationExtensionsClientInput.CredProtect.ProtectionPolicy.USER_VERIFICATION_REQUIRED,
true);
CredProtectAuthenticationExtensionsClientInput credProtectInput = new CredProtectAuthenticationExtensionsClientInput(
credProtect);
ImmutableAuthenticationExtensionsClientInputs clientInputs = new ImmutableAuthenticationExtensionsClientInputs(
credProtectInput);
String actual = this.mapper.writeValueAsString(clientInputs);
JSONAssert.assertEquals(expected, actual, false);
}
@Test
void writeAuthenticationExtensionsClientInputsWhenEnforceCredentialProtectionPolicyTrue() throws Exception {
String expected = """
{
"credentialProtectionPolicy": "userVerificationOptional",
"enforceCredentialProtectionPolicy": true
}
""";
CredProtectAuthenticationExtensionsClientInput.CredProtect credProtect = new CredProtectAuthenticationExtensionsClientInput.CredProtect(
CredProtectAuthenticationExtensionsClientInput.CredProtect.ProtectionPolicy.USER_VERIFICATION_OPTIONAL,
true);
CredProtectAuthenticationExtensionsClientInput credProtectInput = new CredProtectAuthenticationExtensionsClientInput(
credProtect);
ImmutableAuthenticationExtensionsClientInputs clientInputs = new ImmutableAuthenticationExtensionsClientInputs(
credProtectInput);
String actual = this.mapper.writeValueAsString(clientInputs);
JSONAssert.assertEquals(expected, actual, false);
}
@Test
void writeAuthenticationExtensionsClientInputsWhenEnforceCredentialProtectionPolicyFalse() throws Exception {
String expected = """
{
"credentialProtectionPolicy": "userVerificationOptional",
"enforceCredentialProtectionPolicy": false
}
""";
CredProtectAuthenticationExtensionsClientInput.CredProtect credProtect = new CredProtectAuthenticationExtensionsClientInput.CredProtect(
CredProtectAuthenticationExtensionsClientInput.CredProtect.ProtectionPolicy.USER_VERIFICATION_OPTIONAL,
false);
CredProtectAuthenticationExtensionsClientInput credProtectInput = new CredProtectAuthenticationExtensionsClientInput(
credProtect);
ImmutableAuthenticationExtensionsClientInputs clientInputs = new ImmutableAuthenticationExtensionsClientInputs(
credProtectInput);
String actual = this.mapper.writeValueAsString(clientInputs);
JSONAssert.assertEquals(expected, actual, false);
}
}

View File

@@ -0,0 +1,363 @@
/*
* Copyright 2002-2024 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.jackson;
import java.time.Duration;
import java.util.Arrays;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.skyscreamer.jsonassert.JSONAssert;
import org.springframework.security.web.webauthn.api.AuthenticationExtensionsClientOutputs;
import org.springframework.security.web.webauthn.api.AuthenticatorAssertionResponse;
import org.springframework.security.web.webauthn.api.AuthenticatorAttachment;
import org.springframework.security.web.webauthn.api.AuthenticatorAttestationResponse;
import org.springframework.security.web.webauthn.api.AuthenticatorTransport;
import org.springframework.security.web.webauthn.api.Bytes;
import org.springframework.security.web.webauthn.api.CredentialPropertiesOutput;
import org.springframework.security.web.webauthn.api.ImmutableAuthenticationExtensionsClientInput;
import org.springframework.security.web.webauthn.api.ImmutableAuthenticationExtensionsClientInputs;
import org.springframework.security.web.webauthn.api.ImmutableAuthenticationExtensionsClientOutputs;
import org.springframework.security.web.webauthn.api.PublicKeyCredential;
import org.springframework.security.web.webauthn.api.PublicKeyCredentialCreationOptions;
import org.springframework.security.web.webauthn.api.PublicKeyCredentialRequestOptions;
import org.springframework.security.web.webauthn.api.PublicKeyCredentialType;
import org.springframework.security.web.webauthn.api.TestPublicKeyCredentialCreationOptions;
import org.springframework.security.web.webauthn.api.UserVerificationRequirement;
import static org.assertj.core.api.Assertions.assertThat;
class JacksonTests {
private ObjectMapper mapper;
@BeforeEach
void setup() {
this.mapper = new ObjectMapper();
this.mapper.registerModule(new WebauthnJackson2Module());
}
@Test
void readAuthenticatorTransport() throws Exception {
AuthenticatorTransport transport = this.mapper.readValue("\"hybrid\"", AuthenticatorTransport.class);
assertThat(transport).isEqualTo(AuthenticatorTransport.HYBRID);
}
@Test
void readAuthenticatorAttachment() throws Exception {
AuthenticatorAttachment value = this.mapper.readValue("\"cross-platform\"", AuthenticatorAttachment.class);
assertThat(value).isEqualTo(AuthenticatorAttachment.CROSS_PLATFORM);
}
@Test
void writeAuthenticatorAttachment() throws Exception {
String value = this.mapper.writeValueAsString(AuthenticatorAttachment.CROSS_PLATFORM);
assertThat(value).isEqualTo("\"cross-platform\"");
}
@Test
void readAuthenticationExtensionsClientOutputs() throws Exception {
String json = """
{
"credProps": {
"rk": false
}
}
""";
ImmutableAuthenticationExtensionsClientOutputs clientExtensionResults = new ImmutableAuthenticationExtensionsClientOutputs(
new CredentialPropertiesOutput(false));
AuthenticationExtensionsClientOutputs outputs = this.mapper.readValue(json,
AuthenticationExtensionsClientOutputs.class);
assertThat(outputs).usingRecursiveComparison().isEqualTo(clientExtensionResults);
}
@Test
void readAuthenticationExtensionsClientOutputsWhenAuthenticatorDisplayName() throws Exception {
String json = """
{
"credProps": {
"rk": false,
"authenticatorDisplayName": "1Password"
}
}
""";
ImmutableAuthenticationExtensionsClientOutputs clientExtensionResults = new ImmutableAuthenticationExtensionsClientOutputs(
new CredentialPropertiesOutput(false));
AuthenticationExtensionsClientOutputs outputs = this.mapper.readValue(json,
AuthenticationExtensionsClientOutputs.class);
assertThat(outputs).usingRecursiveComparison().isEqualTo(clientExtensionResults);
}
@Test
void readCredPropsWhenAuthenticatorDisplayName() throws Exception {
String json = """
{
"rk": false,
"authenticatorDisplayName": "1Password"
}
""";
CredentialPropertiesOutput credProps = new CredentialPropertiesOutput(false);
CredentialPropertiesOutput outputs = this.mapper.readValue(json, CredentialPropertiesOutput.class);
assertThat(outputs).usingRecursiveComparison().isEqualTo(credProps);
}
@Test
void readAuthenticationExtensionsClientOutputsWhenFieldAfter() throws Exception {
String json = """
{
"clientOutputs": {
"credProps": {
"rk": false
}
},
"label": "Cell Phone"
}
""";
ImmutableAuthenticationExtensionsClientOutputs clientExtensionResults = new ImmutableAuthenticationExtensionsClientOutputs(
new CredentialPropertiesOutput(false));
ClassWithOutputsAndAnotherField expected = new ClassWithOutputsAndAnotherField();
expected.setClientOutputs(clientExtensionResults);
expected.setLabel("Cell Phone");
ClassWithOutputsAndAnotherField actual = this.mapper.readValue(json, ClassWithOutputsAndAnotherField.class);
assertThat(actual).usingRecursiveComparison().isEqualTo(expected);
}
@Test
void writePublicKeyCredentialCreationOptions() throws Exception {
String expected = """
{
"attestation": "direct",
"authenticatorSelection": {
"residentKey": "required"
},
"challenge": "q7lCdd3SVQxdC-v8pnRAGEn1B2M-t7ZECWPwCAmhWvc",
"excludeCredentials": [],
"extensions": {
"credProps": true
},
"pubKeyCredParams": [
{
"alg": -7,
"type": "public-key"
},{
"alg": -8,
"type": "public-key"
},
{
"alg": -257,
"type": "public-key"
}
],
"rp": {
"id": "example.localhost",
"name": "SimpleWebAuthn Example"
},
"timeout": 300000,
"user": {
"displayName": "user@example.localhost",
"id": "oWJtkJ6vJ_m5b84LB4_K7QKTCTEwLIjCh4tFMCGHO4w",
"name": "user@example.localhost"
}
}
""";
PublicKeyCredentialCreationOptions options = TestPublicKeyCredentialCreationOptions
.createPublicKeyCredentialCreationOptions()
.build();
String string = this.mapper.writeValueAsString(options);
JSONAssert.assertEquals(expected, string, false);
}
@Test
void readPublicKeyCredentialAuthenticatorAttestationResponse() throws Exception {
PublicKeyCredential<AuthenticatorAttestationResponse> publicKeyCredential = this.mapper.readValue(
PublicKeyCredentialJson.PUBLIC_KEY_JSON,
new TypeReference<PublicKeyCredential<AuthenticatorAttestationResponse>>() {
});
ImmutableAuthenticationExtensionsClientOutputs clientExtensionResults = new ImmutableAuthenticationExtensionsClientOutputs(
new CredentialPropertiesOutput(false));
PublicKeyCredential<AuthenticatorAttestationResponse> expected = PublicKeyCredential.builder()
.id("AX6nVVERrH6opMafUGn3Z9EyNEy6cftfBKV_2YxYl1jdW8CSJxMKGXFV3bnrKTiMSJeInkG7C6B2lPt8E5i3KaM")
.rawId(Bytes
.fromBase64("AX6nVVERrH6opMafUGn3Z9EyNEy6cftfBKV_2YxYl1jdW8CSJxMKGXFV3bnrKTiMSJeInkG7C6B2lPt8E5i3KaM"))
.response(AuthenticatorAttestationResponse.builder()
.attestationObject(Bytes.fromBase64(
"o2NmbXRkbm9uZWdhdHRTdG10oGhhdXRoRGF0YVjFSZYN5YgOjGh0NBcPZHZgW4_krrmihjLHmVzzuoMdl2NFAAAAAAAAAAAAAAAAAAAAAAAAAAAAQQF-p1VREax-qKTGn1Bp92fRMjRMunH7XwSlf9mMWJdY3VvAkicTChlxVd256yk4jEiXiJ5BuwugdpT7fBOYtymjpQECAyYgASFYIJK-2epPEw0ujHN-gvVp2Hp3ef8CzU3zqwO5ylx8L2OsIlggK5x5OlTGEPxLS-85TAABum4aqVK4CSWJ7LYDdkjuBLk"))
.clientDataJSON(Bytes.fromBase64(
"eyJ0eXBlIjoid2ViYXV0aG4uY3JlYXRlIiwiY2hhbGxlbmdlIjoiSUJRbnVZMVowSzFIcUJvRldDcDJ4bEpsOC1vcV9hRklYenlUX0YwLTBHVSIsIm9yaWdpbiI6Imh0dHA6Ly9sb2NhbGhvc3Q6ODA4MCIsImNyb3NzT3JpZ2luIjpmYWxzZX0"))
.transports(AuthenticatorTransport.HYBRID, AuthenticatorTransport.INTERNAL)
.build())
.type(PublicKeyCredentialType.PUBLIC_KEY)
.clientExtensionResults(clientExtensionResults)
.authenticatorAttachment(AuthenticatorAttachment.CROSS_PLATFORM)
.build();
assertThat(publicKeyCredential).usingRecursiveComparison().isEqualTo(expected);
}
@Test
void readPublicKeyCredentialAuthenticatorAttestationResponseWhenExtraFields() throws Exception {
final String json = """
{
"attestationObject": "o2NmbXRkbm9uZWdhdHRTdG10oGhhdXRoRGF0YVjFSZYN5YgOjGh0NBcPZHZgW4_krrmihjLHmVzzuoMdl2NFAAAAAAAAAAAAAAAAAAAAAAAAAAAAQQF-p1VREax-qKTGn1Bp92fRMjRMunH7XwSlf9mMWJdY3VvAkicTChlxVd256yk4jEiXiJ5BuwugdpT7fBOYtymjpQECAyYgASFYIJK-2epPEw0ujHN-gvVp2Hp3ef8CzU3zqwO5ylx8L2OsIlggK5x5OlTGEPxLS-85TAABum4aqVK4CSWJ7LYDdkjuBLk",
"clientDataJSON": "eyJ0eXBlIjoid2ViYXV0aG4uY3JlYXRlIiwiY2hhbGxlbmdlIjoiSUJRbnVZMVowSzFIcUJvRldDcDJ4bEpsOC1vcV9hRklYenlUX0YwLTBHVSIsIm9yaWdpbiI6Imh0dHA6Ly9sb2NhbGhvc3Q6ODA4MCIsImNyb3NzT3JpZ2luIjpmYWxzZX0",
"transports": [
"hybrid",
"internal"
],
"publicKeyAlgorithm": -7,
"publicKey": "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEkr7Z6k8TDS6Mc36C9WnYend5_wLNTfOrA7nKXHwvY6wrnHk6VMYQ_EtL7zlMAAG6bhqpUrgJJYnstgN2SO4EuQ",
"authenticatorData": "SZYN5YgOjGh0NBcPZHZgW4_krrmihjLHmVzzuoMdl2NFAAAAAAAAAAAAAAAAAAAAAAAAAAAAQQF-p1VREax-qKTGn1Bp92fRMjRMunH7XwSlf9mMWJdY3VvAkicTChlxVd256yk4jEiXiJ5BuwugdpT7fBOYtymjpQECAyYgASFYIJK-2epPEw0ujHN-gvVp2Hp3ef8CzU3zqwO5ylx8L2OsIlggK5x5OlTGEPxLS-85TAABum4aqVK4CSWJ7LYDdkjuBLk"
}
""";
AuthenticatorAttestationResponse response = this.mapper.readValue(json, AuthenticatorAttestationResponse.class);
ImmutableAuthenticationExtensionsClientOutputs clientExtensionResults = new ImmutableAuthenticationExtensionsClientOutputs(
new CredentialPropertiesOutput(false));
AuthenticatorAttestationResponse expected = AuthenticatorAttestationResponse.builder()
.attestationObject(Bytes.fromBase64(
"o2NmbXRkbm9uZWdhdHRTdG10oGhhdXRoRGF0YVjFSZYN5YgOjGh0NBcPZHZgW4_krrmihjLHmVzzuoMdl2NFAAAAAAAAAAAAAAAAAAAAAAAAAAAAQQF-p1VREax-qKTGn1Bp92fRMjRMunH7XwSlf9mMWJdY3VvAkicTChlxVd256yk4jEiXiJ5BuwugdpT7fBOYtymjpQECAyYgASFYIJK-2epPEw0ujHN-gvVp2Hp3ef8CzU3zqwO5ylx8L2OsIlggK5x5OlTGEPxLS-85TAABum4aqVK4CSWJ7LYDdkjuBLk"))
.clientDataJSON(Bytes.fromBase64(
"eyJ0eXBlIjoid2ViYXV0aG4uY3JlYXRlIiwiY2hhbGxlbmdlIjoiSUJRbnVZMVowSzFIcUJvRldDcDJ4bEpsOC1vcV9hRklYenlUX0YwLTBHVSIsIm9yaWdpbiI6Imh0dHA6Ly9sb2NhbGhvc3Q6ODA4MCIsImNyb3NzT3JpZ2luIjpmYWxzZX0"))
.transports(AuthenticatorTransport.HYBRID, AuthenticatorTransport.INTERNAL)
.build();
assertThat(response).usingRecursiveComparison().isEqualTo(expected);
}
@Test
void writeAuthenticationOptions() throws Exception {
PublicKeyCredentialRequestOptions credentialRequestOptions = PublicKeyCredentialRequestOptions.builder()
.allowCredentials(Arrays.asList())
.challenge(Bytes.fromBase64("I69THX904Q8ONhCgUgOu2PCQCcEjTDiNmokdbgsAsYU"))
.rpId("example.localhost")
.timeout(Duration.ofMinutes(5))
.userVerification(UserVerificationRequirement.REQUIRED)
.build();
String actual = this.mapper.writeValueAsString(credentialRequestOptions);
String expected = """
{
"challenge": "I69THX904Q8ONhCgUgOu2PCQCcEjTDiNmokdbgsAsYU",
"allowCredentials": [],
"timeout": 300000,
"userVerification": "required",
"rpId": "example.localhost"
}
""";
JSONAssert.assertEquals(expected, actual, false);
}
@Test
void readPublicKeyCredentialAuthenticatorAssertionResponse() throws Exception {
String json = """
{
"id": "IquGb208Fffq2cROa1ZxMg",
"rawId": "IquGb208Fffq2cROa1ZxMg",
"response": {
"authenticatorData": "SZYN5YgOjGh0NBcPZHZgW4_krrmihjLHmVzzuoMdl2MdAAAAAA",
"clientDataJSON": "eyJ0eXBlIjoid2ViYXV0aG4uZ2V0IiwiY2hhbGxlbmdlIjoiaDB2Z3dHUWpvQ3pBekRVc216UHBrLUpWSUpSUmduMEw0S1ZTWU5SY0VaYyIsIm9yaWdpbiI6Imh0dHA6Ly9sb2NhbGhvc3Q6ODA4MCIsImNyb3NzT3JpZ2luIjpmYWxzZX0",
"signature": "MEUCIAdfzPAn3voyXynwa0IXk1S0envMY5KP3NEe9aj4B2BuAiEAm_KJhQoWXdvfhbzwACU3NM4ltQe7_Il46qFUwtpuTdg",
"userHandle": "oWJtkJ6vJ_m5b84LB4_K7QKTCTEwLIjCh4tFMCGHO4w"
},
"type": "public-key",
"clientExtensionResults": {},
"authenticatorAttachment": "cross-platform"
}
""";
PublicKeyCredential<AuthenticatorAssertionResponse> publicKeyCredential = this.mapper.readValue(json,
new TypeReference<PublicKeyCredential<AuthenticatorAssertionResponse>>() {
});
ImmutableAuthenticationExtensionsClientOutputs clientExtensionResults = new ImmutableAuthenticationExtensionsClientOutputs();
PublicKeyCredential<AuthenticatorAssertionResponse> expected = PublicKeyCredential.builder()
.id("IquGb208Fffq2cROa1ZxMg")
.rawId(Bytes.fromBase64("IquGb208Fffq2cROa1ZxMg"))
.response(AuthenticatorAssertionResponse.builder()
.authenticatorData(Bytes.fromBase64("SZYN5YgOjGh0NBcPZHZgW4_krrmihjLHmVzzuoMdl2MdAAAAAA"))
.clientDataJSON(Bytes.fromBase64(
"eyJ0eXBlIjoid2ViYXV0aG4uZ2V0IiwiY2hhbGxlbmdlIjoiaDB2Z3dHUWpvQ3pBekRVc216UHBrLUpWSUpSUmduMEw0S1ZTWU5SY0VaYyIsIm9yaWdpbiI6Imh0dHA6Ly9sb2NhbGhvc3Q6ODA4MCIsImNyb3NzT3JpZ2luIjpmYWxzZX0"))
.signature(Bytes.fromBase64(
"MEUCIAdfzPAn3voyXynwa0IXk1S0envMY5KP3NEe9aj4B2BuAiEAm_KJhQoWXdvfhbzwACU3NM4ltQe7_Il46qFUwtpuTdg"))
.userHandle(Bytes.fromBase64("oWJtkJ6vJ_m5b84LB4_K7QKTCTEwLIjCh4tFMCGHO4w"))
.build())
.type(PublicKeyCredentialType.PUBLIC_KEY)
.clientExtensionResults(clientExtensionResults)
.authenticatorAttachment(AuthenticatorAttachment.CROSS_PLATFORM)
.build();
assertThat(publicKeyCredential).usingRecursiveComparison().isEqualTo(expected);
}
@Test
void writeAuthenticationExtensionsClientInputsWhenCredPropsTrue() throws Exception {
String expected = """
{
"credProps": true
}
""";
ImmutableAuthenticationExtensionsClientInputs clientInputs = new ImmutableAuthenticationExtensionsClientInputs(
ImmutableAuthenticationExtensionsClientInput.credProps);
String actual = this.mapper.writeValueAsString(clientInputs);
JSONAssert.assertEquals(expected, actual, false);
}
public static class ClassWithOutputsAndAnotherField {
private String label;
private AuthenticationExtensionsClientOutputs clientOutputs;
public String getLabel() {
return this.label;
}
public void setLabel(String label) {
this.label = label;
}
public AuthenticationExtensionsClientOutputs getClientOutputs() {
return this.clientOutputs;
}
public void setClientOutputs(AuthenticationExtensionsClientOutputs clientOutputs) {
this.clientOutputs = clientOutputs;
}
}
}

View File

@@ -0,0 +1,52 @@
/*
* Copyright 2002-2024 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.jackson;
/**
* JSON for {@code PublicKeyCredential<AuthenticatorAttestationResponse>}
*
* @author Rob Winch
* @since 6.4
*/
public final class PublicKeyCredentialJson {
public static final String PUBLIC_KEY_JSON = """
{
"id": "AX6nVVERrH6opMafUGn3Z9EyNEy6cftfBKV_2YxYl1jdW8CSJxMKGXFV3bnrKTiMSJeInkG7C6B2lPt8E5i3KaM",
"rawId": "AX6nVVERrH6opMafUGn3Z9EyNEy6cftfBKV_2YxYl1jdW8CSJxMKGXFV3bnrKTiMSJeInkG7C6B2lPt8E5i3KaM",
"response": {
"attestationObject": "o2NmbXRkbm9uZWdhdHRTdG10oGhhdXRoRGF0YVjFSZYN5YgOjGh0NBcPZHZgW4_krrmihjLHmVzzuoMdl2NFAAAAAAAAAAAAAAAAAAAAAAAAAAAAQQF-p1VREax-qKTGn1Bp92fRMjRMunH7XwSlf9mMWJdY3VvAkicTChlxVd256yk4jEiXiJ5BuwugdpT7fBOYtymjpQECAyYgASFYIJK-2epPEw0ujHN-gvVp2Hp3ef8CzU3zqwO5ylx8L2OsIlggK5x5OlTGEPxLS-85TAABum4aqVK4CSWJ7LYDdkjuBLk",
"clientDataJSON": "eyJ0eXBlIjoid2ViYXV0aG4uY3JlYXRlIiwiY2hhbGxlbmdlIjoiSUJRbnVZMVowSzFIcUJvRldDcDJ4bEpsOC1vcV9hRklYenlUX0YwLTBHVSIsIm9yaWdpbiI6Imh0dHA6Ly9sb2NhbGhvc3Q6ODA4MCIsImNyb3NzT3JpZ2luIjpmYWxzZX0",
"transports": [
"hybrid",
"internal"
]
},
"type": "public-key",
"clientExtensionResults": {
"credProps": {
"rk": false
}
},
"authenticatorAttachment": "cross-platform"
}
""";
private PublicKeyCredentialJson() {
}
}

View File

@@ -0,0 +1,101 @@
/*
* Copyright 2002-2024 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.springframework.security.web.webauthn.api.PublicKeyCredentialUserEntity;
import org.springframework.security.web.webauthn.api.TestPublicKeyCredentialUserEntity;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatNoException;
/**
* Tests for {@link MapPublicKeyCredentialUserEntityRepository}.
*
* @author Rob Winch
* @since 6.4
*/
class MapPublicKeyCredentialUserEntityRepositoryTests {
private MapPublicKeyCredentialUserEntityRepository userEntities = new MapPublicKeyCredentialUserEntityRepository();
private String username = "username";
private PublicKeyCredentialUserEntity userEntity = TestPublicKeyCredentialUserEntity.userEntity()
.name(this.username)
.build();
@Test
void findByIdWhenExistsThenFound() {
this.userEntities.save(this.userEntity);
PublicKeyCredentialUserEntity findById = this.userEntities.findById(this.userEntity.getId());
assertThat(findById).isEqualTo(this.userEntity);
}
@Test
void findByIdWhenDoesNotExistThenNull() {
PublicKeyCredentialUserEntity findById = this.userEntities.findById(this.userEntity.getId());
assertThat(findById).isNull();
}
@Test
void findByUsernameWhenExistsThenFound() {
this.userEntities.save(this.userEntity);
PublicKeyCredentialUserEntity foundUserEntity = this.userEntities.findByUsername(this.username);
assertThat(foundUserEntity).isEqualTo(this.userEntity);
}
@Test
void findByUsernameReturnsNullWhenUsernameDoesNotExist() {
PublicKeyCredentialUserEntity foundUserEntity = this.userEntities.findByUsername(this.username);
assertThat(foundUserEntity).isNull();
}
@Test
void saveWhenNonNullThenSuccess() {
this.userEntities.save(this.userEntity);
PublicKeyCredentialUserEntity foundUserEntity = this.userEntities.findByUsername(this.username);
assertThat(foundUserEntity).isEqualTo(this.userEntity);
}
@Test
void saveWhenUpdateThenUpdated() {
PublicKeyCredentialUserEntity newUserEntity = TestPublicKeyCredentialUserEntity.userEntity()
.name(this.userEntity.getName())
.displayName("Updated")
.build();
this.userEntities.save(this.userEntity);
this.userEntities.save(newUserEntity);
PublicKeyCredentialUserEntity foundUserEntity = this.userEntities.findByUsername(this.username);
assertThat(foundUserEntity).isEqualTo(newUserEntity);
}
@Test
void deleteWhenExistsThenRemovesExistingEntry() {
this.userEntities.save(this.userEntity);
this.userEntities.delete(this.userEntity.getId());
assertThat(this.userEntities.findByUsername(this.username)).isNull();
assertThat(this.userEntities.findById(this.userEntity.getId())).isNull();
}
@Test
void deleteWhenNullAndDoesNotExistThenNoException() {
assertThatNoException().isThrownBy(() -> this.userEntities.delete(this.userEntity.getId()));
}
}

View File

@@ -0,0 +1,147 @@
/*
* Copyright 2002-2024 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.time.Instant;
import org.junit.jupiter.api.Test;
import org.springframework.security.web.webauthn.api.Bytes;
import org.springframework.security.web.webauthn.api.CredentialRecord;
import org.springframework.security.web.webauthn.api.ImmutableCredentialRecord;
import org.springframework.security.web.webauthn.api.TestCredentialRecord;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
import static org.assertj.core.api.Assertions.assertThatNoException;
/**
* Tests for {@link MapUserCredentialRepository}
*
* @author Rob Winch
* @since 6.4
*/
class MapUserCredentialRepositoryTests {
private final MapUserCredentialRepository userCredentials = new MapUserCredentialRepository();
@Test
void findByUserIdWhenNotFoundThenEmpty() {
assertThat(this.userCredentials.findByUserId(Bytes.random())).isEmpty();
}
@Test
void findByUserIdWhenNullIdThenIllegalArgumentException() {
assertThatIllegalArgumentException().isThrownBy(() -> this.userCredentials.findByUserId(null));
}
@Test
void findByCredentialIdWhenIdNullThenIllegalArgumentException() {
assertThatIllegalArgumentException().isThrownBy(() -> this.userCredentials.findByCredentialId(null));
}
@Test
void findByCredentialIdWhenNotFoundThenIllegalArgumentException() {
assertThat(this.userCredentials.findByCredentialId(Bytes.random())).isNull();
}
@Test
void deleteWhenCredentialNotFoundThenNoException() {
ImmutableCredentialRecord credentialRecord = TestCredentialRecord.userCredential().build();
assertThatNoException().isThrownBy(() -> this.userCredentials.delete(credentialRecord.getCredentialId()));
}
@Test
void deleteWhenNullIdThenIllegalArgumentException() {
assertThatIllegalArgumentException().isThrownBy(() -> this.userCredentials.delete(null));
}
@Test
void saveThenFound() {
ImmutableCredentialRecord credentialRecord = TestCredentialRecord.userCredential().build();
this.userCredentials.save(credentialRecord);
assertThat(this.userCredentials.findByCredentialId(credentialRecord.getCredentialId()))
.isEqualTo(credentialRecord);
assertThat(this.userCredentials.findByUserId(credentialRecord.getUserEntityUserId()))
.containsOnly(credentialRecord);
}
@Test
void saveWhenNullThenIllegalArgumentException() {
assertThatIllegalArgumentException().isThrownBy(() -> this.userCredentials.save(null));
}
@Test
void saveAndDeleteThenNotFound() {
ImmutableCredentialRecord credentialRecord = TestCredentialRecord.userCredential().build();
this.userCredentials.save(credentialRecord);
this.userCredentials.delete(credentialRecord.getCredentialId());
assertThat(this.userCredentials.findByCredentialId(credentialRecord.getCredentialId())).isNull();
assertThat(this.userCredentials.findByUserId(credentialRecord.getUserEntityUserId())).isEmpty();
}
@Test
void saveWhenUpdateThenUpdated() {
ImmutableCredentialRecord credentialRecord = TestCredentialRecord.userCredential().build();
this.userCredentials.save(credentialRecord);
Instant updatedLastUsed = credentialRecord.getLastUsed().plusSeconds(120);
CredentialRecord updatedCredentialRecord = ImmutableCredentialRecord.fromCredentialRecord(credentialRecord)
.lastUsed(updatedLastUsed)
.build();
this.userCredentials.save(updatedCredentialRecord);
assertThat(this.userCredentials.findByCredentialId(credentialRecord.getCredentialId()))
.isEqualTo(updatedCredentialRecord);
assertThat(this.userCredentials.findByUserId(credentialRecord.getUserEntityUserId()))
.containsOnly(updatedCredentialRecord);
}
@Test
void saveWhenSameUserThenUpdated() {
ImmutableCredentialRecord credentialRecord = TestCredentialRecord.userCredential().build();
this.userCredentials.save(credentialRecord);
CredentialRecord newCredentialRecord = ImmutableCredentialRecord.fromCredentialRecord(credentialRecord)
.credentialId(Bytes.random())
.build();
this.userCredentials.save(newCredentialRecord);
assertThat(this.userCredentials.findByCredentialId(credentialRecord.getCredentialId()))
.isEqualTo(credentialRecord);
assertThat(this.userCredentials.findByCredentialId(newCredentialRecord.getCredentialId()))
.isEqualTo(newCredentialRecord);
assertThat(this.userCredentials.findByUserId(credentialRecord.getUserEntityUserId()))
.containsOnly(credentialRecord, newCredentialRecord);
}
@Test
void saveWhenDifferentUserThenNewEntryAdded() {
ImmutableCredentialRecord credentialRecord = TestCredentialRecord.userCredential().build();
this.userCredentials.save(credentialRecord);
CredentialRecord newCredentialRecord = ImmutableCredentialRecord.fromCredentialRecord(credentialRecord)
.userEntityUserId(Bytes.random())
.credentialId(Bytes.random())
.build();
this.userCredentials.save(newCredentialRecord);
assertThat(this.userCredentials.findByCredentialId(credentialRecord.getCredentialId()))
.isEqualTo(credentialRecord);
assertThat(this.userCredentials.findByCredentialId(newCredentialRecord.getCredentialId()))
.isEqualTo(newCredentialRecord);
assertThat(this.userCredentials.findByUserId(credentialRecord.getUserEntityUserId()))
.containsOnly(credentialRecord);
assertThat(this.userCredentials.findByUserId(newCredentialRecord.getUserEntityUserId()))
.containsOnly(newCredentialRecord);
}
}

View File

@@ -0,0 +1,30 @@
/*
* Copyright 2002-2024 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.springframework.security.web.webauthn.api.PublicKeyCredentialRpEntity;
public final class TestPublicKeyCredentialRpEntity {
public static PublicKeyCredentialRpEntity.PublicKeyCredentialRpEntityBuilder createRpEntity() {
return PublicKeyCredentialRpEntity.builder().id("example.localhost").name("Spring Security Relying Party");
}
private TestPublicKeyCredentialRpEntity() {
}
}

View File

@@ -0,0 +1,539 @@
/*
* Copyright 2002-2024 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.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.Arrays;
import java.util.Map;
import java.util.Set;
import com.fasterxml.jackson.core.Base64Variants;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.JsonNodeFactory;
import com.fasterxml.jackson.databind.node.ObjectNode;
import com.fasterxml.jackson.dataformat.cbor.CBORFactory;
import com.webauthn4j.converter.AttestationObjectConverter;
import com.webauthn4j.converter.util.ObjectConverter;
import com.webauthn4j.data.attestation.AttestationObject;
import com.webauthn4j.data.attestation.authenticator.AuthenticatorData;
import com.webauthn4j.data.extension.authenticator.RegistrationExtensionAuthenticatorOutput;
import org.assertj.core.api.recursive.comparison.RecursiveComparisonConfiguration;
import org.junit.jupiter.api.BeforeEach;
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.AnonymousAuthenticationToken;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.AuthorityUtils;
import org.springframework.security.web.webauthn.api.AuthenticatorAttestationResponse;
import org.springframework.security.web.webauthn.api.AuthenticatorAttestationResponse.AuthenticatorAttestationResponseBuilder;
import org.springframework.security.web.webauthn.api.AuthenticatorSelectionCriteria;
import org.springframework.security.web.webauthn.api.Bytes;
import org.springframework.security.web.webauthn.api.CredentialRecord;
import org.springframework.security.web.webauthn.api.PublicKeyCredential;
import org.springframework.security.web.webauthn.api.PublicKeyCredentialCreationOptions;
import org.springframework.security.web.webauthn.api.PublicKeyCredentialDescriptor;
import org.springframework.security.web.webauthn.api.PublicKeyCredentialParameters;
import org.springframework.security.web.webauthn.api.PublicKeyCredentialRequestOptions;
import org.springframework.security.web.webauthn.api.PublicKeyCredentialRpEntity;
import org.springframework.security.web.webauthn.api.PublicKeyCredentialUserEntity;
import org.springframework.security.web.webauthn.api.TestAuthenticatorAttestationResponse;
import org.springframework.security.web.webauthn.api.TestCredentialRecord;
import org.springframework.security.web.webauthn.api.TestPublicKeyCredential;
import org.springframework.security.web.webauthn.api.TestPublicKeyCredentialCreationOptions;
import org.springframework.security.web.webauthn.api.TestPublicKeyCredentialUserEntity;
import org.springframework.security.web.webauthn.api.UserVerificationRequirement;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
import static org.assertj.core.api.Assertions.assertThatRuntimeException;
import static org.mockito.BDDMockito.given;
@ExtendWith(MockitoExtension.class)
class Webauthn4jRelyingPartyOperationsTests {
@Mock
private PublicKeyCredentialUserEntityRepository userEntities;
@Mock
private UserCredentialRepository userCredentials;
// AuthenticatorDataFlags.Bitmasks
private static byte UP = 0x01;
private static byte UV = 0x04;
private static byte BE = 0x08;
private static byte BS = 0x10;
private Set<String> origins = Set.of("https://example.localhost:8443");
private UsernamePasswordAuthenticationToken user = new UsernamePasswordAuthenticationToken("user", "password",
AuthorityUtils.createAuthorityList("ROLE_USER"));
private PublicKeyCredentialRpEntity rpEntity = TestPublicKeyCredentialRpEntity.createRpEntity().build();
private Webauthn4JRelyingPartyOperations rpOperations;
@BeforeEach
void setUp() {
this.rpOperations = new Webauthn4JRelyingPartyOperations(this.userEntities, this.userCredentials, this.rpEntity,
this.origins);
}
String label = "Phone";
@Test
void constructorWhenUserEntitiesNullThenIllegalArgumentException() {
assertThatIllegalArgumentException().isThrownBy(
() -> new Webauthn4JRelyingPartyOperations(null, this.userCredentials, this.rpEntity, this.origins));
}
@Test
void constructorWhenUserCredentialsNullThenIllegalArgumentException() {
assertThatIllegalArgumentException().isThrownBy(
() -> new Webauthn4JRelyingPartyOperations(this.userEntities, null, this.rpEntity, this.origins));
}
@Test
void constructorWhenRpEntityNullThenIllegalArgumentException() {
assertThatIllegalArgumentException().isThrownBy(() -> new Webauthn4JRelyingPartyOperations(this.userEntities,
this.userCredentials, null, this.origins));
}
@Test
void constructorWhenOriginsNullThenIllegalArgumentException() {
assertThatIllegalArgumentException().isThrownBy(() -> new Webauthn4JRelyingPartyOperations(this.userEntities,
this.userCredentials, this.rpEntity, null));
}
@Test
void createPublicKeyCredentialCreationOptionsWhenRequestNullThenIllegalArgumentException() {
assertThatIllegalArgumentException()
.isThrownBy(() -> this.rpOperations.createPublicKeyCredentialCreationOptions(null));
}
@Test
void createPublicKeyCredentialCreationOptionsWhenAnonymousThenIllegalArgumentException() {
AnonymousAuthenticationToken anonymous = new AnonymousAuthenticationToken("key", "notAuthenticated",
Set.of(() -> "ROLE_ANOYMOUS"));
assertThatIllegalArgumentException()
.isThrownBy(() -> this.rpOperations.createPublicKeyCredentialCreationOptions(
new ImmutablePublicKeyCredentialCreationOptionsRequest(anonymous)));
}
@Test
void createPublicKeyCredentialCreationOptionsWhenNotIsAuthenticatedThenIllegalArgumentException() {
UsernamePasswordAuthenticationToken notAuthenticated = new UsernamePasswordAuthenticationToken("user",
"password");
assertThatIllegalArgumentException()
.isThrownBy(() -> this.rpOperations.createPublicKeyCredentialCreationOptions(
new ImmutablePublicKeyCredentialCreationOptionsRequest(notAuthenticated)));
}
@Test
void createPublicKeyCredentialCreationOptionsWhenDefaultsThenSuccess() {
PublicKeyCredentialCreationOptions expectedCreationOptions = TestPublicKeyCredentialCreationOptions
.createPublicKeyCredentialCreationOptions()
.rp(this.rpEntity)
.user(TestPublicKeyCredentialUserEntity.userEntity().build())
.build();
PublicKeyCredentialCreationOptions creationOptions = this.rpOperations.createPublicKeyCredentialCreationOptions(
new ImmutablePublicKeyCredentialCreationOptionsRequest(this.user));
assertThat(creationOptions).usingRecursiveComparison()
.ignoringFields("challenge", "user.id")
.isEqualTo(expectedCreationOptions);
// https://www.w3.org/TR/webauthn-3/#dom-publickeycredentialcreationoptions-rp
assertThat(creationOptions.getRp()).isNotNull();
assertThat(creationOptions.getRp().getName()).describedAs("Its values name member is REQUIRED").isNotNull();
// https://www.w3.org/TR/webauthn-3/#dom-publickeycredentialcreationoptions-user
PublicKeyCredentialUserEntity userEntity = creationOptions.getUser();
assertThat(userEntity).isNotNull();
assertThat(userEntity.getName()).isNotNull();
assertThat(userEntity.getDisplayName()).isNotNull();
// https://www.w3.org/TR/webauthn-3/#dom-publickeycredentialuserentity-id
Bytes userId = userEntity.getId();
assertThat(userId).isNotNull();
assertThat(userId.getBytes()).describedAs("user id is a maximum size of 64 bytes").hasSizeLessThanOrEqualTo(64);
assertThat(userId.getBytes())
.describedAs("we want enough entropy in the user id, so it should be at least 16 bytes")
.hasSizeGreaterThanOrEqualTo(16);
Bytes challenge = creationOptions.getChallenge();
assertThat(challenge).isNotNull();
byte[] challengeBytes = challenge.getBytes();
// https://www.w3.org/TR/webauthn-3/#sctn-cryptographic-challenges
assertThat(challengeBytes).describedAs("Challenges should be at least 16 bytes")
.hasSizeGreaterThanOrEqualTo(16);
// https://www.w3.org/TR/webauthn-3/#dom-publickeycredentialcreationoptions-pubkeycredparams
assertThat(creationOptions.getPubKeyCredParams()).describedAs(
"Relying Parties that wish to support a wide range of authenticators SHOULD include at least the following COSEAlgorithmIdentifier values")
.containsExactly(PublicKeyCredentialParameters.EdDSA, PublicKeyCredentialParameters.ES256,
PublicKeyCredentialParameters.RS256);
}
@Test
void createPublicKeyCredentialCreationOptionsWhenCustomizeThenCustomized() {
Duration overriddenTimeout = Duration.ofMinutes(10);
this.rpOperations.setCustomizeCreationOptions((options) -> options.timeout(overriddenTimeout));
PublicKeyCredentialCreationOptions creationOptions = this.rpOperations.createPublicKeyCredentialCreationOptions(
new ImmutablePublicKeyCredentialCreationOptionsRequest(this.user));
assertThat(creationOptions.getTimeout()).isEqualTo(overriddenTimeout);
}
@Test
void createPublicKeyCredentialCreationOptionsWhenExcludesThenSuccess() {
PublicKeyCredentialUserEntity userEntity = TestPublicKeyCredentialUserEntity.userEntity().build();
CredentialRecord credentialRecord = TestCredentialRecord.userCredential().build();
PublicKeyCredentialDescriptor descriptor = PublicKeyCredentialDescriptor.builder()
.id(credentialRecord.getCredentialId())
.transports(credentialRecord.getTransports())
.build();
given(this.userEntities.findByUsername(this.user.getName())).willReturn(userEntity);
given(this.userCredentials.findByUserId(userEntity.getId())).willReturn(Arrays.asList(credentialRecord));
PublicKeyCredentialCreationOptions creationOptions = this.rpOperations.createPublicKeyCredentialCreationOptions(
new ImmutablePublicKeyCredentialCreationOptionsRequest(this.user));
RecursiveComparisonConfiguration configuration = RecursiveComparisonConfiguration.builder().build();
assertThat(creationOptions.getExcludeCredentials()).usingRecursiveFieldByFieldElementComparator(configuration)
.containsOnly(descriptor);
}
// registerCredential
@Test
void registerCredentialWhenRpRegistrationRequestNullThenIllegalArgumentException() {
assertThatIllegalArgumentException().isThrownBy(() -> this.rpOperations.registerCredential(null));
}
@Test
void registerCredentialWhenExistsThenException() {
PublicKeyCredentialCreationOptions creationOptions = TestPublicKeyCredentialCreationOptions
.createPublicKeyCredentialCreationOptions()
.build();
PublicKeyCredential<AuthenticatorAttestationResponse> publicKeyCredential = TestPublicKeyCredential
.createPublicKeyCredential()
.build();
RelyingPartyPublicKey rpPublicKey = new RelyingPartyPublicKey(publicKeyCredential, this.label);
ImmutableRelyingPartyRegistrationRequest rpRegistrationRequest = new ImmutableRelyingPartyRegistrationRequest(
creationOptions, rpPublicKey);
given(this.userCredentials.findByCredentialId(publicKeyCredential.getRawId()))
.willReturn(TestCredentialRecord.userCredential().build());
assertThatRuntimeException().isThrownBy(() -> this.rpOperations.registerCredential(rpRegistrationRequest));
}
/**
* https://www.w3.org/TR/webauthn-3/#sctn-registering-a-new-credential
*
* 7. Verify that the value of C.type is webauthn.create.
*/
@Test
void registerCredentialWhenCTypeIsNotWebAuthn() {
PublicKeyCredentialCreationOptions options = TestPublicKeyCredentialCreationOptions
.createPublicKeyCredentialCreationOptions()
.build();
String originalClientDataJSON = new String(
TestAuthenticatorAttestationResponse.createAuthenticatorAttestationResponse()
.build()
.getClientDataJSON()
.getBytes());
String invalidTypeClientDataJSON = originalClientDataJSON.replace("webauthn.create", "webauthn.INVALID");
AuthenticatorAttestationResponseBuilder responseBldr = TestAuthenticatorAttestationResponse
.createAuthenticatorAttestationResponse()
.clientDataJSON(new Bytes(invalidTypeClientDataJSON.getBytes(StandardCharsets.UTF_8)));
PublicKeyCredential publicKey = TestPublicKeyCredential.createPublicKeyCredential(responseBldr.build()).build();
ImmutableRelyingPartyRegistrationRequest registrationRequest = new ImmutableRelyingPartyRegistrationRequest(
options, new RelyingPartyPublicKey(publicKey, this.label));
assertThatRuntimeException().isThrownBy(() -> this.rpOperations.registerCredential(registrationRequest))
.withMessageContaining("ClientData.type");
}
/**
* https://www.w3.org/TR/webauthn-3/#sctn-registering-a-new-credential
*
* 8. Verify that the value of C.challengeBytes equals the base64url encoding of
* options.challengeBytes.
*/
@Test
void registerCredentialWhenCChallengeNotEqualBase64UrlEncodingOptionsChallenge() {
PublicKeyCredentialCreationOptions options = TestPublicKeyCredentialCreationOptions
.createPublicKeyCredentialCreationOptions()
// change the expected challenge so it does not match
.challenge(Bytes.fromBase64("h0vgwGQjoCzAzDUsmzPpk-JVIJRRgn0L4KVSYNRcEZc"))
.build();
AuthenticatorAttestationResponseBuilder responseBldr = TestAuthenticatorAttestationResponse
.createAuthenticatorAttestationResponse();
PublicKeyCredential publicKey = TestPublicKeyCredential.createPublicKeyCredential(responseBldr.build()).build();
ImmutableRelyingPartyRegistrationRequest registrationRequest = new ImmutableRelyingPartyRegistrationRequest(
options, new RelyingPartyPublicKey(publicKey, this.label));
assertThatRuntimeException().isThrownBy(() -> this.rpOperations.registerCredential(registrationRequest))
.withMessageContaining("challenge");
}
/**
* https://www.w3.org/TR/webauthn-3/#sctn-registering-a-new-credential
*
* 9. Verify that the value of C.origin is an origin expected by the Relying Party.
* See §13.4.9 Validating the origin of a credential for guidance.
*/
@Test
void registerCredentialWhenCOriginNotExpected() {
this.rpOperations = new Webauthn4JRelyingPartyOperations(this.userEntities, this.userCredentials, this.rpEntity,
Set.of("https://doesnotmatch.localhost:8443"));
PublicKeyCredentialCreationOptions options = TestPublicKeyCredentialCreationOptions
.createPublicKeyCredentialCreationOptions()
.build();
AuthenticatorAttestationResponseBuilder responseBldr = TestAuthenticatorAttestationResponse
.createAuthenticatorAttestationResponse();
PublicKeyCredential publicKey = TestPublicKeyCredential.createPublicKeyCredential(responseBldr.build()).build();
ImmutableRelyingPartyRegistrationRequest registrationRequest = new ImmutableRelyingPartyRegistrationRequest(
options, new RelyingPartyPublicKey(publicKey, this.label));
assertThatRuntimeException().isThrownBy(() -> this.rpOperations.registerCredential(registrationRequest))
.withMessageContaining("origin");
}
// FIXME: Need to add 10. If C.topOrigin is present
// https://www.w3.org/TR/webauthn-3/#sctn-registering-a-new-credential
/**
* https://www.w3.org/TR/webauthn-3/#sctn-registering-a-new-credential
*
* 13. Verify that the rpIdHash in authData is the SHA-256 hash of the RP ID expected
* by the Relying Party.
*/
@Test
void registerCredentialWhenClientDataJSONDoesNotMatchHash() {
PublicKeyCredentialCreationOptions options = TestPublicKeyCredentialCreationOptions
.createPublicKeyCredentialCreationOptions()
.rp(PublicKeyCredentialRpEntity.builder().id("invalid").name("Spring Security").build())
.build();
AuthenticatorAttestationResponseBuilder responseBldr = TestAuthenticatorAttestationResponse
.createAuthenticatorAttestationResponse();
PublicKeyCredential publicKey = TestPublicKeyCredential.createPublicKeyCredential(responseBldr.build()).build();
ImmutableRelyingPartyRegistrationRequest registrationRequest = new ImmutableRelyingPartyRegistrationRequest(
options, new RelyingPartyPublicKey(publicKey, this.label));
assertThatRuntimeException().isThrownBy(() -> this.rpOperations.registerCredential(registrationRequest))
.withMessageContaining("hash");
}
/**
* https://www.w3.org/TR/webauthn-3/#sctn-registering-a-new-credential
*
* 14. Verify that the UP bit of the flags in authData is set.
*/
@Test
void registerCredentialWhenUPFlagsNotSet() throws Exception {
PublicKeyCredentialCreationOptions options = TestPublicKeyCredentialCreationOptions
.createPublicKeyCredentialCreationOptions()
.build();
PublicKeyCredential publicKey = TestPublicKeyCredential.createPublicKeyCredential(setFlag(UP)).build();
ImmutableRelyingPartyRegistrationRequest registrationRequest = new ImmutableRelyingPartyRegistrationRequest(
options, new RelyingPartyPublicKey(publicKey, this.label));
assertThatRuntimeException().isThrownBy(() -> this.rpOperations.registerCredential(registrationRequest))
.withMessageContaining("UP flag");
}
/**
* https://www.w3.org/TR/webauthn-3/#sctn-registering-a-new-credential
*
* 15. If the Relying Party requires user verification for this registration, verify
* that the UV bit of the flags in authData is set.
*/
@Test
void registerCredentialWhenUVBitNotSet() throws Exception {
PublicKeyCredentialCreationOptions options = TestPublicKeyCredentialCreationOptions
.createPublicKeyCredentialCreationOptions()
.authenticatorSelection(AuthenticatorSelectionCriteria.builder()
.userVerification(UserVerificationRequirement.REQUIRED)
.build())
.build();
PublicKeyCredential publicKey = TestPublicKeyCredential.createPublicKeyCredential(setFlag(UV)).build();
ImmutableRelyingPartyRegistrationRequest registrationRequest = new ImmutableRelyingPartyRegistrationRequest(
options, new RelyingPartyPublicKey(publicKey, this.label));
assertThatRuntimeException().isThrownBy(() -> this.rpOperations.registerCredential(registrationRequest))
.withMessageContaining("UV flag");
}
/**
* https://www.w3.org/TR/webauthn-3/#sctn-registering-a-new-credential
*
* 16. If the BE bit of the flags in authData is not set, verify that the BS bit is
* not set.
*/
@Test
void registerCredentialWhenBENotSetAndBSSet() throws Exception {
PublicKeyCredentialCreationOptions options = TestPublicKeyCredentialCreationOptions
.createPublicKeyCredentialCreationOptions()
.build();
PublicKeyCredential publicKey = TestPublicKeyCredential.createPublicKeyCredential(setFlag(BE)).build();
ImmutableRelyingPartyRegistrationRequest registrationRequest = new ImmutableRelyingPartyRegistrationRequest(
options, new RelyingPartyPublicKey(publicKey, this.label));
assertThatRuntimeException().isThrownBy(() -> this.rpOperations.registerCredential(registrationRequest))
.withMessageContaining("Backup state bit");
}
/**
* https://www.w3.org/TR/webauthn-3/#sctn-registering-a-new-credential
*
* 17. If the Relying Party uses the credentials backup eligibility to inform its
* user experience flows and/or policies, evaluate the BE bit of the flags in
* authData.
*/
@Test
void registerCredentialWhenBEInformsUserExperienceBETrue() {
// FIXME: Implement this
}
/**
* https://www.w3.org/TR/webauthn-3/#sctn-registering-a-new-credential
*
* 18. If the Relying Party uses the credentials backup state to inform its user
* experience flows and/or policies, evaluate the BS bit of the flags in authData.
*/
@Test
void registerCredentialWhenBSInformsUserExperienceBSTrue() {
// FIXME: Search for AuthenticatorDataFlags.BS to implement
}
/**
* https://www.w3.org/TR/webauthn-3/#sctn-registering-a-new-credential
*
* 19. Verify that the "alg" parameter in the credential public key in authData
* matches the alg attribute of one of the items in options.pubKeyCredParams.
*/
@Test
void registerCredentialWhenAlgDoesNotMatchOptions() {
PublicKeyCredentialCreationOptions options = TestPublicKeyCredentialCreationOptions
.createPublicKeyCredentialCreationOptions()
.pubKeyCredParams(PublicKeyCredentialParameters.RS1)
.build();
PublicKeyCredential<AuthenticatorAttestationResponse> publicKey = TestPublicKeyCredential
.createPublicKeyCredential()
.build();
ImmutableRelyingPartyRegistrationRequest registrationRequest = new ImmutableRelyingPartyRegistrationRequest(
options, new RelyingPartyPublicKey(publicKey, this.label));
assertThatRuntimeException().isThrownBy(() -> this.rpOperations.registerCredential(registrationRequest))
.withMessageContaining("options.pubKeyCredParams");
}
/**
* https://www.w3.org/TR/webauthn-3/#sctn-registering-a-new-credential
*
* 20. Verify that the values of the client extension outputs in
* clientExtensionResults and the authenticator extension outputs in the extensions in
* authData are as expected, considering the client extension input values that were
* given in options.extensions and any specific policy of the Relying Party regarding
* unsolicited extensions, i.e., those that were not specified as part of
* options.extensions. In the general case, the meaning of "are as expected" is
* specific to the Relying Party and which extensions are in use.
*/
@Test
void registerCredentialWhenClientExtensionOutputsDoNotMatch() {
// FIXME: Implement this
}
/**
* https://www.w3.org/TR/webauthn-3/#reg-ceremony-verify-attestation
*
* 22. Verify that attStmt is a correct attestation statement, conveying a valid
* attestation signature, by using the attestation statement format fmts verification
* procedure given attStmt, authData and hash.
*/
@Test
void registerCredentialWhenFmtNotValid() throws Exception {
PublicKeyCredentialCreationOptions options = TestPublicKeyCredentialCreationOptions
.createPublicKeyCredentialCreationOptions()
.build();
PublicKeyCredential publicKey = TestPublicKeyCredential.createPublicKeyCredential() // setFmt("packed")
.build();
ImmutableRelyingPartyRegistrationRequest registrationRequest = new ImmutableRelyingPartyRegistrationRequest(
options, new RelyingPartyPublicKey(publicKey, this.label));
// FIXME: Implement this test
// assertThatThrownBy(() ->
// this.rpOperations.registerCredential(registrationRequest)).hasMessageContaining("Flag
// combination is invalid");
}
@Test
void createCredentialRequestOptionsThenUserVerificationSameAsCreation() {
PublicKeyCredentialCreationOptions creationOptions = this.rpOperations.createPublicKeyCredentialCreationOptions(
new ImmutablePublicKeyCredentialCreationOptionsRequest(this.user));
PublicKeyCredentialRequestOptionsRequest createRequest = new ImmutablePublicKeyCredentialRequestOptionsRequest(
this.user);
PublicKeyCredentialRequestOptions credentialRequestOptions = this.rpOperations
.createCredentialRequestOptions(createRequest);
assertThat(credentialRequestOptions.getUserVerification())
.isEqualTo(creationOptions.getAuthenticatorSelection().getUserVerification());
}
private static AuthenticatorAttestationResponse setFlag(byte... flags) throws Exception {
AuthenticatorAttestationResponseBuilder authAttResponseBldr = TestAuthenticatorAttestationResponse
.createAuthenticatorAttestationResponse();
byte[] originalAttestationObjBytes = authAttResponseBldr.build().getAttestationObject().getBytes();
ObjectMapper cbor = cbor();
AttestationObjectConverter attestationObjectConverter = new AttestationObjectConverter(new ObjectConverter());
ObjectNode attObj = (ObjectNode) cbor.readTree(originalAttestationObjBytes);
byte[] rawAuthData = attObj.get("authData").binaryValue();
for (byte flag : flags) {
rawAuthData[32] ^= flag;
}
JsonNodeFactory f = JsonNodeFactory.instance;
byte[] updatedAttObjBytes = cbor
.writeValueAsBytes(attObj.setAll(Map.of("authData", f.binaryNode(rawAuthData))));
AttestationObject attestationObject = attestationObjectConverter.convert(updatedAttObjBytes);
AuthenticatorData<RegistrationExtensionAuthenticatorOutput> authenticatorData = attestationObject
.getAuthenticatorData();
boolean flagBE = authenticatorData.isFlagBE();
boolean flagBS = authenticatorData.isFlagBS();
authAttResponseBldr.attestationObject(new Bytes(updatedAttObjBytes));
return authAttResponseBldr.build();
}
private static AuthenticatorAttestationResponse setFmt(String fmt) throws Exception {
AuthenticatorAttestationResponseBuilder authAttResponseBldr = TestAuthenticatorAttestationResponse
.createAuthenticatorAttestationResponse();
byte[] originalAttestationObjBytes = authAttResponseBldr.build().getAttestationObject().getBytes();
ObjectMapper cbor = cbor();
ObjectNode attObj = (ObjectNode) cbor.readTree(originalAttestationObjBytes);
JsonNodeFactory f = JsonNodeFactory.instance;
byte[] updatedAttObjBytes = cbor.writeValueAsBytes(attObj.setAll(Map.of("fmt", f.textNode(fmt))));
authAttResponseBldr.attestationObject(new Bytes(updatedAttObjBytes));
return authAttResponseBldr.build();
}
private static ObjectMapper cbor() {
return new ObjectMapper(new CBORFactory()).setBase64Variant(Base64Variants.MODIFIED_FOR_URL);
}
}

View File

@@ -0,0 +1,295 @@
/*
* Copyright 2002-2024 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.registration;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.util.Arrays;
import java.util.Collections;
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.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.mock.web.MockHttpServletResponse;
import org.springframework.security.web.csrf.CsrfToken;
import org.springframework.security.web.csrf.DefaultCsrfToken;
import org.springframework.security.web.webauthn.api.Bytes;
import org.springframework.security.web.webauthn.api.ImmutableCredentialRecord;
import org.springframework.security.web.webauthn.api.ImmutablePublicKeyCredentialUserEntity;
import org.springframework.security.web.webauthn.api.PublicKeyCredentialUserEntity;
import org.springframework.security.web.webauthn.api.TestCredentialRecord;
import org.springframework.security.web.webauthn.management.PublicKeyCredentialUserEntityRepository;
import org.springframework.security.web.webauthn.management.UserCredentialRepository;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.MvcResult;
import org.springframework.test.web.servlet.RequestBuilder;
import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.web.util.HtmlUtils;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
@ExtendWith(MockitoExtension.class)
class DefaultWebAuthnRegistrationPageGeneratingFilterTests {
@Mock
private PublicKeyCredentialUserEntityRepository userEntities;
@Mock
private UserCredentialRepository userCredentials;
@Test
void constructorWhenNullUserEntities() {
assertThatIllegalArgumentException()
.isThrownBy(() -> new DefaultWebAuthnRegistrationPageGeneratingFilter(null, this.userCredentials))
.withMessage("userEntities cannot be null");
}
@Test
void constructorWhenNullUserCredentials() {
assertThatIllegalArgumentException()
.isThrownBy(() -> new DefaultWebAuthnRegistrationPageGeneratingFilter(this.userEntities, null))
.withMessage("userCredentials cannot be null");
}
@Test
void doFilterWhenNotMatchThenNoInteractions() throws Exception {
MockMvc mockMvc = mockMvc();
mockMvc.perform(get("/not-match"));
verifyNoInteractions(this.userEntities, this.userCredentials);
}
@Test
void doFilterThenCsrfDataAttrsPresent() throws Exception {
PublicKeyCredentialUserEntity userEntity = ImmutablePublicKeyCredentialUserEntity.builder()
.name("user")
.id(Bytes.random())
.displayName("User")
.build();
given(this.userEntities.findByUsername(any())).willReturn(userEntity);
given(this.userCredentials.findByUserId(userEntity.getId()))
.willReturn(Arrays.asList(TestCredentialRecord.userCredential().build()));
String body = bodyAsString(matchingRequest());
assertThat(body).contains("setupRegistration({\"X-CSRF-TOKEN\" : \"CSRF_TOKEN\"}");
assertThat(body.replaceAll("\\s", "")).contains("""
<form class="delete-form no-margin" method="post" action="/webauthn/register/NauGCN7bZ5jEBwThcde51g">
<input type="hidden" name="method" value="delete">
<input type="hidden" name="_csrf" value="CSRF_TOKEN">
<button class="primary small" type="submit">Delete</button>
</form>""".replaceAll("\\s", ""));
}
@Test
void doFilterWhenNullPublicKeyCredentialUserEntityThenNoResults() throws Exception {
String body = bodyAsString(matchingRequest());
assertThat(body).contains("No Passkeys");
verifyNoInteractions(this.userCredentials);
}
@Test
void doFilterWhenNoCredentialsThenNoResults() throws Exception {
PublicKeyCredentialUserEntity userEntity = ImmutablePublicKeyCredentialUserEntity.builder()
.name("user")
.id(Bytes.random())
.displayName("User")
.build();
given(this.userEntities.findByUsername(any())).willReturn(userEntity);
given(this.userCredentials.findByUserId(userEntity.getId())).willReturn(Collections.emptyList());
String body = bodyAsString(matchingRequest());
assertThat(body).contains("No Passkeys");
verify(this.userCredentials).findByUserId(any());
}
@Test
void doFilterWhenResultsThenDisplayed() throws Exception {
PublicKeyCredentialUserEntity userEntity = ImmutablePublicKeyCredentialUserEntity.builder()
.name("user")
.id(Bytes.random())
.displayName("User")
.build();
ImmutableCredentialRecord credential = TestCredentialRecord.userCredential()
.created(LocalDateTime.of(2024, 9, 17, 10, 10, 42, 999_999_999).toInstant(ZoneOffset.UTC))
.lastUsed(LocalDateTime.of(2024, 9, 18, 11, 11, 42, 999_999_999).toInstant(ZoneOffset.UTC))
.build();
given(this.userEntities.findByUsername(any())).willReturn(userEntity);
given(this.userCredentials.findByUserId(userEntity.getId())).willReturn(Arrays.asList(credential));
String body = bodyAsString(matchingRequest());
assertThat(body).isEqualTo(
"""
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<meta name="description" content="">
<meta name="author" content="">
<title>WebAuthn Registration</title>
<link href="/default-ui.css" rel="stylesheet" />
<script type="text/javascript" src="/login/webauthn.js"></script>
<script type="text/javascript">
<!--
const ui = {
getRegisterButton: function() {
return document.getElementById('register')
},
getSuccess: function() {
return document.getElementById('success')
},
getError: function() {
return document.getElementById('error')
},
getLabelInput: function() {
return document.getElementById('label')
},
getDeleteForms: function() {
return Array.from(document.getElementsByClassName("delete-form"))
},
}
document.addEventListener("DOMContentLoaded",() => setupRegistration({"X-CSRF-TOKEN" : "CSRF_TOKEN"}, "", ui));
//-->
</script>
</head>
<body>
<div class="content">
<h2 class="center">WebAuthn Registration</h2>
<form class="default-form" method="post" action="#" onclick="return false">
<div id="success" class="alert alert-success" role="alert">Success!</div>
<div id="error" class="alert alert-danger" role="alert"></div>
<p>
<label for="label" class="screenreader">Passkey Label</label>
<input type="text" id="label" name="label" placeholder="Passkey Label" required autofocus>
</p>
<button id="register" class="primary" type="submit">Register</button>
</form>
<table class="table table-striped">
<thead>
<tr class="table-header">
<th>Label</th>
<th>Created</th>
<th>Last Used</th>
<th>Signature Count</th>
<th>Delete</th>
</tr>
</thead>
<tbody>
<tr class="v-middle">
<td>label</td>
<td>2024-09-17T10:10:42Z</td>
<td>2024-09-18T11:11:42Z</td>
<td class="center">0</td>
<td>
<form class="delete-form no-margin" method="post" action="/webauthn/register/NauGCN7bZ5jEBwThcde51g">
<input type="hidden" name="method" value="delete">
<input type="hidden" name="_csrf" value="CSRF_TOKEN">
<button class="primary small" type="submit">Delete</button>
</form>
</td>
</tr>
</tbody>
</table>
</div>
</body>
</html>
""");
}
@Test
void doFilterWhenResultsContainEntitiesThenEncoded() throws Exception {
String label = "<script>alert('Hello');</script>";
String htmlEncodedLabel = HtmlUtils.htmlEscape(label);
assertThat(label).isNotEqualTo(htmlEncodedLabel);
PublicKeyCredentialUserEntity userEntity = ImmutablePublicKeyCredentialUserEntity.builder()
.name("user")
.id(Bytes.random())
.displayName("User")
.build();
ImmutableCredentialRecord credential = TestCredentialRecord.userCredential().label(label).build();
given(this.userEntities.findByUsername(any())).willReturn(userEntity);
given(this.userCredentials.findByUserId(userEntity.getId())).willReturn(Arrays.asList(credential));
String body = bodyAsString(matchingRequest());
assertThat(body).doesNotContain(credential.getLabel());
assertThat(body).contains(htmlEncodedLabel);
}
@Test
void doFilterWhenContextEmptyThenUrlsEmptyPrefix() throws Exception {
PublicKeyCredentialUserEntity userEntity = ImmutablePublicKeyCredentialUserEntity.builder()
.name("user")
.id(Bytes.random())
.displayName("User")
.build();
ImmutableCredentialRecord credential = TestCredentialRecord.userCredential().build();
given(this.userEntities.findByUsername(any())).willReturn(userEntity);
given(this.userCredentials.findByUserId(userEntity.getId())).willReturn(Arrays.asList(credential));
String body = bodyAsString(matchingRequest());
assertThat(body).contains("<script type=\"text/javascript\" src=\"/login/webauthn.js\"></script>");
assertThat(body).contains(
"document.addEventListener(\"DOMContentLoaded\",() => setupRegistration({\"X-CSRF-TOKEN\" : \"CSRF_TOKEN\"}, \"\",");
}
@Test
void doFilterWhenContextNotEmptyThenUrlsPrefixed() throws Exception {
PublicKeyCredentialUserEntity userEntity = ImmutablePublicKeyCredentialUserEntity.builder()
.name("user")
.id(Bytes.random())
.displayName("User")
.build();
ImmutableCredentialRecord credential = TestCredentialRecord.userCredential().build();
given(this.userEntities.findByUsername(any())).willReturn(userEntity);
given(this.userCredentials.findByUserId(userEntity.getId())).willReturn(Arrays.asList(credential));
String body = bodyAsString(matchingRequest("/foo"));
assertThat(body).contains("<script type=\"text/javascript\" src=\"/foo/login/webauthn.js\"></script>");
assertThat(body).contains("setupRegistration({\"X-CSRF-TOKEN\" : \"CSRF_TOKEN\"}, \"/foo\",");
}
private String bodyAsString(RequestBuilder request) throws Exception {
MockMvc mockMvc = mockMvc();
MvcResult result = mockMvc.perform(request).andReturn();
MockHttpServletResponse response = result.getResponse();
assertThat(response.getStatus()).isEqualTo(HttpStatus.OK.value());
assertThat(response.getContentType()).isEqualTo(MediaType.TEXT_HTML_VALUE);
return response.getContentAsString();
}
private MockHttpServletRequestBuilder matchingRequest() {
return matchingRequest("");
}
private MockHttpServletRequestBuilder matchingRequest(String contextPath) {
DefaultCsrfToken token = new DefaultCsrfToken("X-CSRF-TOKEN", "_csrf", "CSRF_TOKEN");
return get(contextPath + "/webauthn/register").contextPath(contextPath)
.requestAttr(CsrfToken.class.getName(), token);
}
private MockMvc mockMvc() {
return MockMvcBuilders.standaloneSetup(new Object())
.addFilter(new DefaultWebAuthnRegistrationPageGeneratingFilter(this.userEntities, this.userCredentials))
.build();
}
}

View File

@@ -0,0 +1,96 @@
/*
* Copyright 2002-2024 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.registration;
import org.junit.jupiter.api.Test;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockHttpServletResponse;
import org.springframework.security.web.webauthn.api.PublicKeyCredentialCreationOptions;
import org.springframework.security.web.webauthn.api.TestPublicKeyCredentialCreationOptions;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Tests for
* {@link org.springframework.security.web.webauthn.authentication.HttpSessionPublicKeyCredentialRequestOptionsRepository}.
*
* @author Rob Winch
* @since 6.4
*/
class HttpSessionPublicKeyCredentialCreationOptionsRepositoryTests {
private HttpSessionPublicKeyCredentialCreationOptionsRepository repository = new HttpSessionPublicKeyCredentialCreationOptionsRepository();
private MockHttpServletRequest request = new MockHttpServletRequest();
private MockHttpServletResponse response = new MockHttpServletResponse();
@Test
void integrationTests() {
PublicKeyCredentialCreationOptions expected = TestPublicKeyCredentialCreationOptions
.createPublicKeyCredentialCreationOptions()
.build();
this.repository.save(this.request, this.response, expected);
Object attrValue = this.request.getSession()
.getAttribute(HttpSessionPublicKeyCredentialCreationOptionsRepository.DEFAULT_ATTR_NAME);
assertThat(attrValue).isEqualTo(expected);
PublicKeyCredentialCreationOptions loadOptions = this.repository.load(this.request);
assertThat(loadOptions).isEqualTo(expected);
this.repository.save(this.request, this.response, null);
Object attrValueAfterRemoval = this.request.getSession()
.getAttribute(HttpSessionPublicKeyCredentialCreationOptionsRepository.DEFAULT_ATTR_NAME);
assertThat(attrValueAfterRemoval).isNull();
assertThat(this.request.getSession().getAttributeNames())
.doesNotHaveToString(HttpSessionPublicKeyCredentialCreationOptionsRepository.DEFAULT_ATTR_NAME);
PublicKeyCredentialCreationOptions loadOptionsAfterRemoval = this.repository.load(this.request);
assertThat(loadOptionsAfterRemoval).isNull();
}
@Test
void loadWhenNullSessionThenDoesNotCreate() {
PublicKeyCredentialCreationOptions options = this.repository.load(this.request);
assertThat(options).isNull();
assertThat(this.request.getSession(false)).isNull();
}
@Test
void saveWhenSetAttrThenCustomAttr() {
PublicKeyCredentialCreationOptions expected = TestPublicKeyCredentialCreationOptions
.createPublicKeyCredentialCreationOptions()
.build();
String customAttr = "custom-attr";
this.repository.setAttrName(customAttr);
this.repository.save(this.request, this.response, expected);
assertThat(this.request.getSession().getAttribute(customAttr)).isEqualTo(expected);
}
@Test
void loadWhenSetAttrThenCustomAttr() {
PublicKeyCredentialCreationOptions expected = TestPublicKeyCredentialCreationOptions
.createPublicKeyCredentialCreationOptions()
.build();
String customAttr = "custom-attr";
this.repository.setAttrName(customAttr);
this.request.getSession().setAttribute(customAttr, expected);
PublicKeyCredentialCreationOptions actual = this.repository.load(this.request);
assertThat(actual).isEqualTo(expected);
}
}

View File

@@ -0,0 +1,206 @@
/*
* Copyright 2002-2024 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.registration;
import java.util.Arrays;
import org.junit.jupiter.api.AfterEach;
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.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.mock.web.MockHttpServletResponse;
import org.springframework.security.authentication.AnonymousAuthenticationToken;
import org.springframework.security.authentication.TestingAuthenticationToken;
import org.springframework.security.core.authority.AuthorityUtils;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.context.SecurityContextImpl;
import org.springframework.security.web.webauthn.api.AuthenticatorTransport;
import org.springframework.security.web.webauthn.api.Bytes;
import org.springframework.security.web.webauthn.api.PublicKeyCredentialCreationOptions;
import org.springframework.security.web.webauthn.api.PublicKeyCredentialDescriptor;
import org.springframework.security.web.webauthn.api.TestPublicKeyCredentialCreationOptions;
import org.springframework.security.web.webauthn.management.WebAuthnRelyingPartyOperations;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.verifyNoInteractions;
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;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/**
* Tests for {@link PublicKeyCredentialCreationOptionsFilter}.
*
* @author Rob Winch
* @since 6.4
*/
@ExtendWith(MockitoExtension.class)
class PublicKeyCredentialCreationOptionsFilterTests {
private static String REGISTER_OPTONS_URL = "/webauthn/register/options";
@Mock
private WebAuthnRelyingPartyOperations rpOperations;
@AfterEach
void clear() {
SecurityContextHolder.clearContext();
}
@Test
void constructorWhenRpOperationsIsNullThenIllegalArgumentException() {
assertThatIllegalArgumentException().isThrownBy(() -> new PublicKeyCredentialCreationOptionsFilter(null))
.withMessage("rpOperations cannot be null");
}
@Test
void doFilterWhenWrongUrlThenNoInteractions() throws Exception {
MockMvc mockMvc = mockMvc();
mockMvc.perform(post("/foo"));
verifyNoInteractions(this.rpOperations);
}
@Test
void doFilterWhenNotAuthenticatedThenNoInvocations() throws Exception {
MockMvc mockMvc = mockMvc();
MockHttpServletResponse response = mockMvc.perform(post(REGISTER_OPTONS_URL)).andReturn().getResponse();
assertThat(response.getStatus()).isEqualTo(HttpStatus.BAD_REQUEST.value());
}
@Test
void doFilterWhenAnonymousThenNoInvocations() throws Exception {
AnonymousAuthenticationToken anonymous = new AnonymousAuthenticationToken("key", "anonymousUser",
AuthorityUtils.createAuthorityList("ROLE_ANONYMOUS"));
SecurityContextImpl context = new SecurityContextImpl(anonymous);
SecurityContextHolder.setContext(context);
MockMvc mockMvc = mockMvc();
MockHttpServletResponse response = mockMvc.perform(post(REGISTER_OPTONS_URL)).andReturn().getResponse();
assertThat(response.getStatus()).isEqualTo(HttpStatus.BAD_REQUEST.value());
}
@Test
void doFilterWhenGetThenNoInteractions() throws Exception {
MockMvc mockMvc = mockMvc();
mockMvc.perform(get(REGISTER_OPTONS_URL));
verifyNoInteractions(this.rpOperations);
}
@Test
void doFilterWhenNoCredentials() throws Exception {
PublicKeyCredentialCreationOptions options = TestPublicKeyCredentialCreationOptions
.createPublicKeyCredentialCreationOptions()
.build();
given(this.rpOperations.createPublicKeyCredentialCreationOptions(any())).willReturn(options);
MockMvc mockMvc = mockMvc();
mockMvc.perform(matchingRequest())
.andExpect(header().string(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE))
.andExpect(status().isOk())
.andExpect(content().json("""
{
"rp": {
"name": "SimpleWebAuthn Example",
"id": "example.localhost"
},
"user": {
"name": "user@example.localhost",
"id": "oWJtkJ6vJ_m5b84LB4_K7QKTCTEwLIjCh4tFMCGHO4w",
"displayName": "user@example.localhost"
},
"challenge": "q7lCdd3SVQxdC-v8pnRAGEn1B2M-t7ZECWPwCAmhWvc",
"pubKeyCredParams": [
{
"type": "public-key",
"alg": -8
},
{
"type": "public-key",
"alg": -7
},
{
"type": "public-key",
"alg": -257
}
],
"timeout": 300000,
"excludeCredentials": [],
"authenticatorSelection": {
"residentKey": "required",
"userVerification": "preferred"
},
"attestation": "direct",
"extensions": {
"credProps": true
}
}
"""));
}
@Test
void doFilterWhenExcludeCredentialsThenIncludedInResponse() throws Exception {
PublicKeyCredentialDescriptor credentialDescriptor = PublicKeyCredentialDescriptor.builder()
.transports(AuthenticatorTransport.HYBRID)
.id(Bytes.fromBase64("ChfoCM8CJA_wwUGDdzdtuw"))
.build();
PublicKeyCredentialCreationOptions options = TestPublicKeyCredentialCreationOptions
.createPublicKeyCredentialCreationOptions()
.excludeCredentials(Arrays.asList(credentialDescriptor))
.build();
given(this.rpOperations.createPublicKeyCredentialCreationOptions(any())).willReturn(options);
MockMvc mockMvc = mockMvc();
mockMvc.perform(matchingRequest())
.andExpect(header().string(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE))
.andExpect(status().isOk())
.andExpect(content().json("""
{
"excludeCredentials": [
{
"type": "public-key",
"id": "ChfoCM8CJA_wwUGDdzdtuw",
"transports": [
"hybrid"
]
}
]
}
"""));
}
private MockHttpServletRequestBuilder matchingRequest() {
TestingAuthenticationToken user = new TestingAuthenticationToken("user", "password", "ROLE_USER");
SecurityContextHolder.setContext(new SecurityContextImpl(user));
return post(REGISTER_OPTONS_URL);
}
private MockMvc mockMvc() {
return MockMvcBuilders.standaloneSetup(new Object())
.addFilter(new PublicKeyCredentialCreationOptionsFilter(this.rpOperations))
.build();
}
}

View File

@@ -0,0 +1,197 @@
/*
* Copyright 2002-2024 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.registration;
import jakarta.servlet.FilterChain;
import jakarta.servlet.http.HttpServletResponse;
import org.junit.jupiter.api.BeforeEach;
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.skyscreamer.jsonassert.JSONAssert;
import org.springframework.http.HttpStatus;
import org.springframework.http.converter.GenericHttpMessageConverter;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockHttpServletResponse;
import org.springframework.mock.web.MockServletContext;
import org.springframework.security.web.webauthn.api.ImmutableCredentialRecord;
import org.springframework.security.web.webauthn.api.PublicKeyCredentialCreationOptions;
import org.springframework.security.web.webauthn.api.TestCredentialRecord;
import org.springframework.security.web.webauthn.api.TestPublicKeyCredentialCreationOptions;
import org.springframework.security.web.webauthn.management.UserCredentialRepository;
import org.springframework.security.web.webauthn.management.WebAuthnRelyingPartyOperations;
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
/**
* Tests for {@link WebAuthnRegistrationFilter}.
*
* @author Rob Winch
* @since 6.4
*/
@ExtendWith(MockitoExtension.class)
class WebAuthnRegistrationFilterTests {
@Mock
private UserCredentialRepository userCredentials;
@Mock
private WebAuthnRelyingPartyOperations operations;
@Mock
private GenericHttpMessageConverter<Object> converter;
@Mock
private PublicKeyCredentialCreationOptionsRepository creationOptionsRepository;
@Mock
private FilterChain chain;
private MockHttpServletResponse response = new MockHttpServletResponse();
private static final String REGISTRATION_REQUEST_BODY = """
{
"publicKey": {
"credential": {
"id": "dYF7EGnRFFIXkpXi9XU2wg",
"rawId": "dYF7EGnRFFIXkpXi9XU2wg",
"response": {
"attestationObject": "o2NmbXRkbm9uZWdhdHRTdG10oGhhdXRoRGF0YViUy9GqwTRaMpzVDbXq1dyEAXVOxrou08k22ggRC45MKNhdAAAAALraVWanqkAfvZZFYZpVEg0AEHWBexBp0RRSF5KV4vV1NsKlAQIDJiABIVggQjmrekPGzyqtoKK9HPUH-8Z2FLpoqkklFpFPQVICQ3IiWCD6I9Jvmor685fOZOyGXqUd87tXfvJk8rxj9OhuZvUALA",
"clientDataJSON": "eyJ0eXBlIjoid2ViYXV0aG4uY3JlYXRlIiwiY2hhbGxlbmdlIjoiSl9RTi10SFJYRWVKYjlNcUNrWmFPLUdOVmlibXpGVGVWMk43Z0ptQUdrQSIsIm9yaWdpbiI6Imh0dHBzOi8vZXhhbXBsZS5sb2NhbGhvc3Q6ODQ0MyIsImNyb3NzT3JpZ2luIjpmYWxzZX0",
"transports": [
"internal",
"hybrid"
]
},
"type": "public-key",
"clientExtensionResults": {},
"authenticatorAttachment": "platform"
},
"label": "1password"
}
}
""";
private WebAuthnRegistrationFilter filter;
@BeforeEach
void setup() {
this.filter = new WebAuthnRegistrationFilter(this.userCredentials, this.operations);
}
@Test
void constructorWhenNullUserCredentials() {
assertThatIllegalArgumentException().isThrownBy(() -> new WebAuthnRegistrationFilter(null, this.operations));
}
@Test
void constructorWhenNullOperations() {
assertThatIllegalArgumentException()
.isThrownBy(() -> new WebAuthnRegistrationFilter(this.userCredentials, null));
}
@Test
void doFilterWhenRegisterUrlDoesNotMatchThenChainContinues() throws Exception {
HttpServletResponse response = mock(HttpServletResponse.class);
this.filter.setConverter(this.converter);
this.filter.setCreationOptionsRepository(this.creationOptionsRepository);
this.filter.doFilter(post("/nomatch").buildRequest(new MockServletContext()), response, this.chain);
verifyNoInteractions(this.converter, this.creationOptionsRepository, response);
verify(this.chain).doFilter(any(), any());
}
@Test
void doFilterWhenRegisterMethodDoesNotMatchThenChainContinues() throws Exception {
HttpServletResponse response = mock(HttpServletResponse.class);
this.filter.setConverter(this.converter);
this.filter.setCreationOptionsRepository(this.creationOptionsRepository);
this.filter.doFilter(
get(WebAuthnRegistrationFilter.DEFAULT_REGISTER_CREDENTIAL_URL).buildRequest(new MockServletContext()),
response, this.chain);
verifyNoInteractions(this.converter, this.creationOptionsRepository, response);
verify(this.chain).doFilter(any(), any());
}
@Test
void doFilterWhenRegisterNoBodyThenBadRequest() throws Exception {
this.filter.doFilter(registerCredentialRequest(""), this.response, this.chain);
assertThat(this.response.getStatus()).isEqualTo(HttpStatus.BAD_REQUEST.value());
}
@Test
void doFilterWhenInvalidJsonThenBadRequest() throws Exception {
this.filter.doFilter(registerCredentialRequest("{"), this.response, this.chain);
assertThat(this.response.getStatus()).isEqualTo(HttpStatus.BAD_REQUEST.value());
}
@Test
void doFilterWhenRegisterOptionsNullThenBadRequest() throws Exception {
this.filter.setCreationOptionsRepository(this.creationOptionsRepository);
MockHttpServletRequest request = registerCredentialRequest(REGISTRATION_REQUEST_BODY);
this.filter.doFilter(request, this.response, this.chain);
assertThat(this.response.getStatus()).isEqualTo(HttpStatus.BAD_REQUEST.value());
}
@Test
void doFilterWhenRegisterSuccessThenOk() throws Exception {
this.filter.setCreationOptionsRepository(this.creationOptionsRepository);
PublicKeyCredentialCreationOptions creationOptions = TestPublicKeyCredentialCreationOptions
.createPublicKeyCredentialCreationOptions()
.build();
given(this.creationOptionsRepository.load(any())).willReturn(creationOptions);
ImmutableCredentialRecord userCredential = TestCredentialRecord.userCredential().build();
given(this.operations.registerCredential(any())).willReturn(userCredential);
MockHttpServletRequest request = registerCredentialRequest(REGISTRATION_REQUEST_BODY);
this.filter.doFilter(request, this.response, this.chain);
assertThat(this.response.getStatus()).isEqualTo(HttpStatus.OK.value());
String actualBody = this.response.getContentAsString();
String expectedBody = """
{
"success": true
}
""";
JSONAssert.assertEquals(expectedBody, actualBody, false);
verify(this.creationOptionsRepository).save(any(), any(), eq(null));
}
@Test
void doFilterWhenDeleteSuccessThenNoContent() throws Exception {
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());
}
private static MockHttpServletRequest registerCredentialRequest(String body) {
return MockMvcRequestBuilders.post(WebAuthnRegistrationFilter.DEFAULT_REGISTER_CREDENTIAL_URL)
.content(body)
.buildRequest(new MockServletContext());
}
}

View File

@@ -0,0 +1,88 @@
/*
* Copyright 2002-2024 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.registration;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.security.web.webauthn.api.AuthenticatorAttachment;
import org.springframework.security.web.webauthn.api.AuthenticatorAttestationResponse;
import org.springframework.security.web.webauthn.api.AuthenticatorTransport;
import org.springframework.security.web.webauthn.api.Bytes;
import org.springframework.security.web.webauthn.api.CredentialPropertiesOutput;
import org.springframework.security.web.webauthn.api.ImmutableAuthenticationExtensionsClientOutputs;
import org.springframework.security.web.webauthn.api.PublicKeyCredential;
import org.springframework.security.web.webauthn.api.PublicKeyCredentialType;
import org.springframework.security.web.webauthn.jackson.PublicKeyCredentialJson;
import org.springframework.security.web.webauthn.jackson.WebauthnJackson2Module;
import org.springframework.security.web.webauthn.management.RelyingPartyPublicKey;
import static org.assertj.core.api.Assertions.assertThat;
/**
* @author Rob Winch
* @since 6.4
*/
class WebAuthnRegistrationRequestJacksonTests {
private ObjectMapper mapper;
@BeforeEach
void setup() {
this.mapper = new ObjectMapper();
this.mapper.registerModule(new WebauthnJackson2Module());
}
@Test
void readRelyingPartyRequest() throws Exception {
String json = """
{
"publicKey": {
"label": "Cell Phone",
"credential": %s
}
}
""".formatted(PublicKeyCredentialJson.PUBLIC_KEY_JSON);
WebAuthnRegistrationFilter.WebAuthnRegistrationRequest registrationRequest = this.mapper.readValue(json,
WebAuthnRegistrationFilter.WebAuthnRegistrationRequest.class);
ImmutableAuthenticationExtensionsClientOutputs clientExtensionResults = new ImmutableAuthenticationExtensionsClientOutputs(
new CredentialPropertiesOutput(false));
PublicKeyCredential<AuthenticatorAttestationResponse> credential = PublicKeyCredential.builder()
.id("AX6nVVERrH6opMafUGn3Z9EyNEy6cftfBKV_2YxYl1jdW8CSJxMKGXFV3bnrKTiMSJeInkG7C6B2lPt8E5i3KaM")
.rawId(Bytes
.fromBase64("AX6nVVERrH6opMafUGn3Z9EyNEy6cftfBKV_2YxYl1jdW8CSJxMKGXFV3bnrKTiMSJeInkG7C6B2lPt8E5i3KaM"))
.response(AuthenticatorAttestationResponse.builder()
.attestationObject(Bytes.fromBase64(
"o2NmbXRkbm9uZWdhdHRTdG10oGhhdXRoRGF0YVjFSZYN5YgOjGh0NBcPZHZgW4_krrmihjLHmVzzuoMdl2NFAAAAAAAAAAAAAAAAAAAAAAAAAAAAQQF-p1VREax-qKTGn1Bp92fRMjRMunH7XwSlf9mMWJdY3VvAkicTChlxVd256yk4jEiXiJ5BuwugdpT7fBOYtymjpQECAyYgASFYIJK-2epPEw0ujHN-gvVp2Hp3ef8CzU3zqwO5ylx8L2OsIlggK5x5OlTGEPxLS-85TAABum4aqVK4CSWJ7LYDdkjuBLk"))
.clientDataJSON(Bytes.fromBase64(
"eyJ0eXBlIjoid2ViYXV0aG4uY3JlYXRlIiwiY2hhbGxlbmdlIjoiSUJRbnVZMVowSzFIcUJvRldDcDJ4bEpsOC1vcV9hRklYenlUX0YwLTBHVSIsIm9yaWdpbiI6Imh0dHA6Ly9sb2NhbGhvc3Q6ODA4MCIsImNyb3NzT3JpZ2luIjpmYWxzZX0"))
.transports(AuthenticatorTransport.HYBRID, AuthenticatorTransport.INTERNAL)
.build())
.type(PublicKeyCredentialType.PUBLIC_KEY)
.clientExtensionResults(clientExtensionResults)
.authenticatorAttachment(AuthenticatorAttachment.CROSS_PLATFORM)
.build();
WebAuthnRegistrationFilter.WebAuthnRegistrationRequest expected = new WebAuthnRegistrationFilter.WebAuthnRegistrationRequest();
expected.setPublicKey(new RelyingPartyPublicKey(credential, "Cell Phone"));
assertThat(registrationRequest).usingRecursiveComparison().isEqualTo(expected);
}
}