Add Passkeys Support
Closes gh-13305
This commit is contained in:
+26
@@ -67,6 +67,7 @@ import org.springframework.security.config.annotation.web.configurers.RequestCac
|
||||
import org.springframework.security.config.annotation.web.configurers.SecurityContextConfigurer;
|
||||
import org.springframework.security.config.annotation.web.configurers.ServletApiConfigurer;
|
||||
import org.springframework.security.config.annotation.web.configurers.SessionManagementConfigurer;
|
||||
import org.springframework.security.config.annotation.web.configurers.WebAuthnConfigurer;
|
||||
import org.springframework.security.config.annotation.web.configurers.X509Configurer;
|
||||
import org.springframework.security.config.annotation.web.configurers.oauth2.client.OAuth2ClientConfigurer;
|
||||
import org.springframework.security.config.annotation.web.configurers.oauth2.client.OAuth2LoginConfigurer;
|
||||
@@ -3674,6 +3675,31 @@ public final class HttpSecurity extends AbstractConfiguredSecurityBuilder<Defaul
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Specifies webAuthn/passkeys based authentication.
|
||||
*
|
||||
* <pre>
|
||||
* @Bean
|
||||
* SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
|
||||
* http
|
||||
* // ...
|
||||
* .webAuthn((webAuthn) -> webAuthn
|
||||
* .rpName("Spring Security Relying Party")
|
||||
* .rpId("example.com")
|
||||
* .allowedOrigins("https://example.com")
|
||||
* );
|
||||
* return http.build();
|
||||
* }
|
||||
* </pre>
|
||||
* @param webAuthn the customizer to apply
|
||||
* @return the {@link HttpSecurity} for further customizations
|
||||
* @throws Exception
|
||||
*/
|
||||
public HttpSecurity webAuthn(Customizer<WebAuthnConfigurer<HttpSecurity>> webAuthn) throws Exception {
|
||||
webAuthn.customize(getOrApply(new WebAuthnConfigurer<HttpSecurity>()));
|
||||
return HttpSecurity.this;
|
||||
}
|
||||
|
||||
private List<RequestMatcher> createAntMatchers(String... patterns) {
|
||||
List<RequestMatcher> matchers = new ArrayList<>(patterns.length);
|
||||
for (String pattern : patterns) {
|
||||
|
||||
+194
@@ -0,0 +1,194 @@
|
||||
/*
|
||||
* 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.config.annotation.web.configurers;
|
||||
|
||||
import java.lang.reflect.Constructor;
|
||||
import java.util.HashSet;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
|
||||
import org.springframework.beans.factory.NoSuchBeanDefinitionException;
|
||||
import org.springframework.context.ApplicationContext;
|
||||
import org.springframework.core.io.ClassPathResource;
|
||||
import org.springframework.http.HttpMethod;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.security.authentication.ProviderManager;
|
||||
import org.springframework.security.config.annotation.web.HttpSecurityBuilder;
|
||||
import org.springframework.security.core.userdetails.UserDetailsService;
|
||||
import org.springframework.security.web.access.intercept.AuthorizationFilter;
|
||||
import org.springframework.security.web.authentication.ui.DefaultLoginPageGeneratingFilter;
|
||||
import org.springframework.security.web.authentication.ui.DefaultResourcesFilter;
|
||||
import org.springframework.security.web.authentication.www.BasicAuthenticationFilter;
|
||||
import org.springframework.security.web.csrf.CsrfToken;
|
||||
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
|
||||
import org.springframework.security.web.util.matcher.RequestMatcher;
|
||||
import org.springframework.security.web.webauthn.api.PublicKeyCredentialRpEntity;
|
||||
import org.springframework.security.web.webauthn.authentication.PublicKeyCredentialRequestOptionsFilter;
|
||||
import org.springframework.security.web.webauthn.authentication.WebAuthnAuthenticationFilter;
|
||||
import org.springframework.security.web.webauthn.authentication.WebAuthnAuthenticationProvider;
|
||||
import org.springframework.security.web.webauthn.management.MapPublicKeyCredentialUserEntityRepository;
|
||||
import org.springframework.security.web.webauthn.management.MapUserCredentialRepository;
|
||||
import org.springframework.security.web.webauthn.management.PublicKeyCredentialUserEntityRepository;
|
||||
import org.springframework.security.web.webauthn.management.UserCredentialRepository;
|
||||
import org.springframework.security.web.webauthn.management.WebAuthnRelyingPartyOperations;
|
||||
import org.springframework.security.web.webauthn.management.Webauthn4JRelyingPartyOperations;
|
||||
import org.springframework.security.web.webauthn.registration.DefaultWebAuthnRegistrationPageGeneratingFilter;
|
||||
import org.springframework.security.web.webauthn.registration.PublicKeyCredentialCreationOptionsFilter;
|
||||
import org.springframework.security.web.webauthn.registration.WebAuthnRegistrationFilter;
|
||||
|
||||
import static org.springframework.security.web.util.matcher.AntPathRequestMatcher.antMatcher;
|
||||
|
||||
/**
|
||||
* Configures WebAuthn for Spring Security applications
|
||||
*
|
||||
* @param <H> the type of builder
|
||||
* @author Rob Winch
|
||||
* @since 6.4
|
||||
*/
|
||||
public class WebAuthnConfigurer<H extends HttpSecurityBuilder<H>>
|
||||
extends AbstractHttpConfigurer<WebAuthnConfigurer<H>, H> {
|
||||
|
||||
private String rpId;
|
||||
|
||||
private String rpName;
|
||||
|
||||
private Set<String> allowedOrigins = new HashSet<>();
|
||||
|
||||
/**
|
||||
* The Relying Party id.
|
||||
* @param rpId the relying party id
|
||||
* @return the {@link WebAuthnConfigurer} for further customization
|
||||
*/
|
||||
public WebAuthnConfigurer<H> rpId(String rpId) {
|
||||
this.rpId = rpId;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the relying party name
|
||||
* @param rpName the relying party name
|
||||
* @return the {@link WebAuthnConfigurer} for further customization
|
||||
*/
|
||||
public WebAuthnConfigurer<H> rpName(String rpName) {
|
||||
this.rpName = rpName;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience method for {@link #allowedOrigins(Set)}
|
||||
* @param allowedOrigins the allowed origins
|
||||
* @return the {@link WebAuthnConfigurer} for further customization
|
||||
* @see #allowedOrigins(Set)
|
||||
*/
|
||||
public WebAuthnConfigurer<H> allowedOrigins(String... allowedOrigins) {
|
||||
return allowedOrigins(Set.of(allowedOrigins));
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the allowed origins.
|
||||
* @param allowedOrigins the allowed origins
|
||||
* @return the {@link WebAuthnConfigurer} for further customization
|
||||
* @see #allowedOrigins(String...)
|
||||
*/
|
||||
public WebAuthnConfigurer<H> allowedOrigins(Set<String> allowedOrigins) {
|
||||
this.allowedOrigins = allowedOrigins;
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void configure(H http) throws Exception {
|
||||
UserDetailsService userDetailsService = getSharedOrBean(http, UserDetailsService.class).orElseGet(() -> {
|
||||
throw new IllegalStateException("Missing UserDetailsService Bean");
|
||||
});
|
||||
PublicKeyCredentialUserEntityRepository userEntities = getSharedOrBean(http,
|
||||
PublicKeyCredentialUserEntityRepository.class)
|
||||
.orElse(userEntityRepository());
|
||||
UserCredentialRepository userCredentials = getSharedOrBean(http, UserCredentialRepository.class)
|
||||
.orElse(userCredentialRepository());
|
||||
WebAuthnRelyingPartyOperations rpOperations = webAuthnRelyingPartyOperations(userEntities, userCredentials);
|
||||
WebAuthnAuthenticationFilter webAuthnAuthnFilter = new WebAuthnAuthenticationFilter();
|
||||
webAuthnAuthnFilter.setAuthenticationManager(
|
||||
new ProviderManager(new WebAuthnAuthenticationProvider(rpOperations, userDetailsService)));
|
||||
http.addFilterBefore(webAuthnAuthnFilter, BasicAuthenticationFilter.class);
|
||||
http.addFilterAfter(new WebAuthnRegistrationFilter(userCredentials, rpOperations), AuthorizationFilter.class);
|
||||
http.addFilterBefore(new PublicKeyCredentialCreationOptionsFilter(rpOperations), AuthorizationFilter.class);
|
||||
http.addFilterAfter(new DefaultWebAuthnRegistrationPageGeneratingFilter(userEntities, userCredentials),
|
||||
AuthorizationFilter.class);
|
||||
http.addFilterBefore(new PublicKeyCredentialRequestOptionsFilter(rpOperations), AuthorizationFilter.class);
|
||||
DefaultLoginPageGeneratingFilter loginPageGeneratingFilter = http
|
||||
.getSharedObject(DefaultLoginPageGeneratingFilter.class);
|
||||
if (loginPageGeneratingFilter != null) {
|
||||
ClassPathResource webauthn = new ClassPathResource(
|
||||
"org/springframework/security/spring-security-webauthn.js");
|
||||
AntPathRequestMatcher matcher = antMatcher(HttpMethod.GET, "/login/webauthn.js");
|
||||
|
||||
Constructor<DefaultResourcesFilter> constructor = DefaultResourcesFilter.class
|
||||
.getDeclaredConstructor(RequestMatcher.class, ClassPathResource.class, MediaType.class);
|
||||
constructor.setAccessible(true);
|
||||
DefaultResourcesFilter resourcesFilter = constructor.newInstance(matcher, webauthn,
|
||||
MediaType.parseMediaType("text/javascript"));
|
||||
http.addFilter(resourcesFilter);
|
||||
DefaultLoginPageGeneratingFilter loginGeneratingFilter = http
|
||||
.getSharedObject(DefaultLoginPageGeneratingFilter.class);
|
||||
loginGeneratingFilter.setPasskeysEnabled(true);
|
||||
loginGeneratingFilter.setResolveHeaders((request) -> {
|
||||
CsrfToken csrfToken = (CsrfToken) request.getAttribute(CsrfToken.class.getName());
|
||||
return Map.of(csrfToken.getHeaderName(), csrfToken.getToken());
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private <C> Optional<C> getSharedOrBean(H http, Class<C> type) {
|
||||
C shared = http.getSharedObject(type);
|
||||
return Optional.ofNullable(shared).or(() -> getBeanOrNull(type));
|
||||
}
|
||||
|
||||
private <T> Optional<T> getBeanOrNull(Class<T> type) {
|
||||
ApplicationContext context = getBuilder().getSharedObject(ApplicationContext.class);
|
||||
if (context == null) {
|
||||
return Optional.empty();
|
||||
}
|
||||
try {
|
||||
return Optional.of(context.getBean(type));
|
||||
}
|
||||
catch (NoSuchBeanDefinitionException ex) {
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
|
||||
private MapUserCredentialRepository userCredentialRepository() {
|
||||
return new MapUserCredentialRepository();
|
||||
}
|
||||
|
||||
private PublicKeyCredentialUserEntityRepository userEntityRepository() {
|
||||
return new MapPublicKeyCredentialUserEntityRepository();
|
||||
}
|
||||
|
||||
private WebAuthnRelyingPartyOperations webAuthnRelyingPartyOperations(
|
||||
PublicKeyCredentialUserEntityRepository userEntities, UserCredentialRepository userCredentials) {
|
||||
Optional<WebAuthnRelyingPartyOperations> webauthnOperationsBean = getBeanOrNull(
|
||||
WebAuthnRelyingPartyOperations.class);
|
||||
if (webauthnOperationsBean.isPresent()) {
|
||||
return webauthnOperationsBean.get();
|
||||
}
|
||||
Webauthn4JRelyingPartyOperations result = new Webauthn4JRelyingPartyOperations(userEntities, userCredentials,
|
||||
PublicKeyCredentialRpEntity.builder().id(this.rpId).name(this.rpName).build(), this.allowedOrigins);
|
||||
return result;
|
||||
}
|
||||
|
||||
}
|
||||
+31
@@ -1031,6 +1031,37 @@ class HttpSecurityDsl(private val http: HttpSecurity, private val init: HttpSecu
|
||||
this.http.rememberMe(rememberMeCustomizer)
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable WebAuthn configuration.
|
||||
*
|
||||
* Example:
|
||||
*
|
||||
* ```
|
||||
* @Configuration
|
||||
* @EnableWebSecurity
|
||||
* class SecurityConfig {
|
||||
*
|
||||
* @Bean
|
||||
* fun securityFilterChain(http: HttpSecurity): SecurityFilterChain {
|
||||
* http {
|
||||
* webAuthn {
|
||||
* loginPage = "/log-in"
|
||||
* }
|
||||
* }
|
||||
* return http.build()
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* @param webAuthnConfiguration custom configurations to be applied
|
||||
* to the WebAuthn authentication
|
||||
* @see [WebAuthnDsl]
|
||||
*/
|
||||
fun webAuthn(webAuthnConfiguration: WebAuthnDsl.() -> Unit) {
|
||||
val webAuthnCustomizer = WebAuthnDsl().apply(webAuthnConfiguration).get()
|
||||
this.http.webAuthn(webAuthnCustomizer)
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds the [Filter] at the location of the specified [Filter] class.
|
||||
*
|
||||
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
/*
|
||||
* Copyright 2002-2021 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.config.annotation.web
|
||||
|
||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity
|
||||
import org.springframework.security.config.annotation.web.configurers.WebAuthnConfigurer
|
||||
|
||||
/**
|
||||
* A Kotlin DSL to configure [HttpSecurity] webauthn using idiomatic Kotlin code.
|
||||
* @property rpName the relying party name
|
||||
* @property rpId the relying party id
|
||||
* @property the allowed origins
|
||||
* @since 6.4
|
||||
* @author Rob Winch
|
||||
*/
|
||||
@SecurityMarker
|
||||
class WebAuthnDsl {
|
||||
var rpName: String? = null
|
||||
var rpId: String? = null
|
||||
var allowedOrigins: Set<String>? = null
|
||||
|
||||
internal fun get(): (WebAuthnConfigurer<HttpSecurity>) -> Unit {
|
||||
return { webAuthn -> webAuthn
|
||||
.rpId(rpId)
|
||||
.rpName(rpName)
|
||||
.allowedOrigins(allowedOrigins);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -53,6 +53,7 @@ class X509Dsl {
|
||||
var authenticationUserDetailsService: AuthenticationUserDetailsService<PreAuthenticatedAuthenticationToken>? = null
|
||||
var subjectPrincipalRegex: String? = null
|
||||
|
||||
|
||||
internal fun get(): (X509Configurer<HttpSecurity>) -> Unit {
|
||||
return { x509 ->
|
||||
x509AuthenticationFilter?.also { x509.x509AuthenticationFilter(x509AuthenticationFilter) }
|
||||
|
||||
+83
@@ -0,0 +1,83 @@
|
||||
/*
|
||||
* Copyright 2002-2022 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.config.annotation.web
|
||||
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.extension.ExtendWith
|
||||
import org.springframework.beans.factory.annotation.Autowired
|
||||
import org.springframework.context.annotation.Bean
|
||||
import org.springframework.context.annotation.Configuration
|
||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity
|
||||
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity
|
||||
import org.springframework.security.config.test.SpringTestContext
|
||||
import org.springframework.security.config.test.SpringTestContextExtension
|
||||
import org.springframework.security.core.userdetails.User
|
||||
import org.springframework.security.core.userdetails.UserDetailsService
|
||||
import org.springframework.security.provisioning.InMemoryUserDetailsManager
|
||||
import org.springframework.security.web.SecurityFilterChain
|
||||
import org.springframework.test.web.servlet.MockMvc
|
||||
import org.springframework.test.web.servlet.post
|
||||
|
||||
/**
|
||||
* Tests for [WebAuthnDsl]
|
||||
*
|
||||
* @author Rob Winch
|
||||
*/
|
||||
@ExtendWith(SpringTestContextExtension::class)
|
||||
class WebAuthnDslTests {
|
||||
@JvmField
|
||||
val spring = SpringTestContext(this)
|
||||
|
||||
@Autowired
|
||||
lateinit var mockMvc: MockMvc
|
||||
|
||||
@Test
|
||||
fun `default configuration`() {
|
||||
this.spring.register(WebauthnConfig::class.java).autowire()
|
||||
|
||||
this.mockMvc.post("/test1")
|
||||
.andExpect {
|
||||
status { isForbidden() }
|
||||
}
|
||||
}
|
||||
|
||||
@Configuration
|
||||
@EnableWebSecurity
|
||||
open class WebauthnConfig {
|
||||
@Bean
|
||||
open fun securityFilterChain(http: HttpSecurity): SecurityFilterChain {
|
||||
http {
|
||||
webAuthn {
|
||||
rpName = "Spring Security Relying Party"
|
||||
rpId = "example.com"
|
||||
allowedOrigins = setOf("https://example.com")
|
||||
}
|
||||
}
|
||||
return http.build()
|
||||
}
|
||||
|
||||
@Bean
|
||||
open fun userDetailsService(): UserDetailsService {
|
||||
val userDetails = User.withDefaultPasswordEncoder()
|
||||
.username("rod")
|
||||
.password("password")
|
||||
.roles("USER")
|
||||
.build()
|
||||
return InMemoryUserDetailsManager(userDetails)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user