diff --git a/config/src/main/java/org/springframework/security/config/annotation/authentication/configurers/mfa/MultiFactorAuthenticationProviderConfigurer.java b/config/src/main/java/org/springframework/security/config/annotation/authentication/configurers/mfa/MultiFactorAuthenticationProviderConfigurer.java new file mode 100644 index 00000000000..dd2000da08f --- /dev/null +++ b/config/src/main/java/org/springframework/security/config/annotation/authentication/configurers/mfa/MultiFactorAuthenticationProviderConfigurer.java @@ -0,0 +1,62 @@ +/* + * Copyright 2002-2018 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.authentication.configurers.mfa; + +import org.springframework.security.authentication.*; +import org.springframework.security.config.annotation.SecurityConfigurerAdapter; +import org.springframework.security.config.annotation.authentication.ProviderManagerBuilder; + +/** + * Allows configuring a {@link MultiFactorAuthenticationProvider} + * + * @param the type of the {@link ProviderManagerBuilder} + * + * @author Yoshikazu Nojima + */ +public class MultiFactorAuthenticationProviderConfigurer> + extends SecurityConfigurerAdapter { + + //~ Instance fields + // ================================================================================================ + private AuthenticationProvider authenticationProvider; + private MFATokenEvaluator mfaTokenEvaluator = new MFATokenEvaluatorImpl(); + + /** + * Constructor + * @param authenticationProvider {@link AuthenticationProvider} to be delegated + */ + public MultiFactorAuthenticationProviderConfigurer(AuthenticationProvider authenticationProvider) { + this.authenticationProvider = authenticationProvider; + } + + + public static MultiFactorAuthenticationProviderConfigurer multiFactorAuthenticationProvider(AuthenticationProvider authenticationProvider){ + return new MultiFactorAuthenticationProviderConfigurer(authenticationProvider); + } + + @Override + public void configure(B builder) { + MultiFactorAuthenticationProvider multiFactorAuthenticationProvider = new MultiFactorAuthenticationProvider(authenticationProvider, mfaTokenEvaluator); + multiFactorAuthenticationProvider = postProcess(multiFactorAuthenticationProvider); + builder.authenticationProvider(multiFactorAuthenticationProvider); + } + + public MultiFactorAuthenticationProviderConfigurer mfaTokenEvaluator(MFATokenEvaluator mfaTokenEvaluator) { + this.mfaTokenEvaluator = mfaTokenEvaluator; + return this; + } +} diff --git a/config/src/main/java/org/springframework/security/config/annotation/web/configuration/WebSecurityConfigurerAdapter.java b/config/src/main/java/org/springframework/security/config/annotation/web/configuration/WebSecurityConfigurerAdapter.java index 5c5976f746e..5de4034f3ce 100644 --- a/config/src/main/java/org/springframework/security/config/annotation/web/configuration/WebSecurityConfigurerAdapter.java +++ b/config/src/main/java/org/springframework/security/config/annotation/web/configuration/WebSecurityConfigurerAdapter.java @@ -40,6 +40,8 @@ import org.springframework.security.authentication.AuthenticationTrustResolver; import org.springframework.security.authentication.AuthenticationTrustResolverImpl; import org.springframework.security.authentication.DefaultAuthenticationEventPublisher; +import org.springframework.security.authentication.MFATokenEvaluator; +import org.springframework.security.authentication.MFATokenEvaluatorImpl; import org.springframework.security.config.annotation.ObjectPostProcessor; import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder; import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration; @@ -113,6 +115,7 @@ public T postProcess(T object) { private boolean authenticationManagerInitialized; private AuthenticationManager authenticationManager; private AuthenticationTrustResolver trustResolver = new AuthenticationTrustResolverImpl(); + private MFATokenEvaluator mfaTokenEvaluator = new MFATokenEvaluatorImpl(); private HttpSecurity http; private boolean disableDefaults; @@ -390,6 +393,11 @@ public void setTrustResolver(AuthenticationTrustResolver trustResolver) { this.trustResolver = trustResolver; } + @Autowired(required = false) + public void setMfaTokenEvaluator(MFATokenEvaluator mfaTokenEvaluator){ + this.mfaTokenEvaluator = mfaTokenEvaluator; + } + @Autowired(required = false) public void setContentNegotationStrategy( ContentNegotiationStrategy contentNegotiationStrategy) { @@ -419,6 +427,7 @@ private Map, Object> createSharedObjects() { sharedObjects.put(ApplicationContext.class, context); sharedObjects.put(ContentNegotiationStrategy.class, contentNegotiationStrategy); sharedObjects.put(AuthenticationTrustResolver.class, trustResolver); + sharedObjects.put(MFATokenEvaluator.class, mfaTokenEvaluator); return sharedObjects; } diff --git a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/ExceptionHandlingConfigurer.java b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/ExceptionHandlingConfigurer.java index 72d03597145..ad741bd50be 100644 --- a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/ExceptionHandlingConfigurer.java +++ b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/ExceptionHandlingConfigurer.java @@ -15,8 +15,7 @@ */ package org.springframework.security.config.annotation.web.configurers; -import java.util.LinkedHashMap; - +import org.springframework.security.authentication.MFATokenEvaluator; import org.springframework.security.config.annotation.web.HttpSecurityBuilder; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.web.AuthenticationEntryPoint; @@ -30,6 +29,8 @@ import org.springframework.security.web.savedrequest.RequestCache; import org.springframework.security.web.util.matcher.RequestMatcher; +import java.util.LinkedHashMap; + /** * Adds exception handling for Spring Security related exceptions to an application. All * properties have reasonable defaults, so no additional configuration is required other @@ -67,6 +68,8 @@ public final class ExceptionHandlingConfigurer> private AuthenticationEntryPoint authenticationEntryPoint; + private MFATokenEvaluator mfaTokenEvaluator; + private AccessDeniedHandler accessDeniedHandler; private LinkedHashMap defaultEntryPointMappings = new LinkedHashMap<>(); @@ -151,6 +154,18 @@ public ExceptionHandlingConfigurer authenticationEntryPoint( return this; } + /** + * Specifies the {@link MFATokenEvaluator} to be used + * + * @param mfaTokenEvaluator the {@link MFATokenEvaluator} to be used + * @return the {@link ExceptionHandlingConfigurer} for further customization + */ + public ExceptionHandlingConfigurer mfaTokenEvaluator( + MFATokenEvaluator mfaTokenEvaluator) { + this.mfaTokenEvaluator = mfaTokenEvaluator; + return this; + } + /** * Sets a default {@link AuthenticationEntryPoint} to be used which prefers being * invoked for the provided {@link RequestMatcher}. If only a single default @@ -194,6 +209,9 @@ public void configure(H http) { entryPoint, getRequestCache(http)); AccessDeniedHandler deniedHandler = getAccessDeniedHandler(http); exceptionTranslationFilter.setAccessDeniedHandler(deniedHandler); + if (mfaTokenEvaluator != null) { + exceptionTranslationFilter.setMFATokenEvaluator(mfaTokenEvaluator); + } exceptionTranslationFilter = postProcess(exceptionTranslationFilter); http.addFilter(exceptionTranslationFilter); } diff --git a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/SessionManagementConfigurer.java b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/SessionManagementConfigurer.java index 84b78c1bb9e..b957e39d4b2 100644 --- a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/SessionManagementConfigurer.java +++ b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/SessionManagementConfigurer.java @@ -15,18 +15,13 @@ */ package org.springframework.security.config.annotation.web.configurers; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.List; -import javax.servlet.http.HttpServletResponse; -import javax.servlet.http.HttpSession; - import org.springframework.context.ApplicationContext; import org.springframework.context.ApplicationListener; import org.springframework.context.event.GenericApplicationListenerAdapter; import org.springframework.context.event.SmartApplicationListener; import org.springframework.security.authentication.AuthenticationTrustResolver; import org.springframework.security.config.Customizer; +import org.springframework.security.authentication.MFATokenEvaluator; import org.springframework.security.config.annotation.web.HttpSecurityBuilder; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.http.SessionCreationPolicy; @@ -48,15 +43,16 @@ import org.springframework.security.web.context.SecurityContextRepository; import org.springframework.security.web.savedrequest.NullRequestCache; import org.springframework.security.web.savedrequest.RequestCache; -import org.springframework.security.web.session.ConcurrentSessionFilter; -import org.springframework.security.web.session.InvalidSessionStrategy; -import org.springframework.security.web.session.SessionInformationExpiredStrategy; -import org.springframework.security.web.session.SessionManagementFilter; -import org.springframework.security.web.session.SimpleRedirectInvalidSessionStrategy; -import org.springframework.security.web.session.SimpleRedirectSessionInformationExpiredStrategy; +import org.springframework.security.web.session.*; import org.springframework.util.Assert; import org.springframework.util.CollectionUtils; +import javax.servlet.http.HttpServletResponse; +import javax.servlet.http.HttpSession; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + /** * Allows configuring session management. * @@ -471,6 +467,11 @@ public void init(H http) { if (trustResolver != null) { httpSecurityRepository.setTrustResolver(trustResolver); } + MFATokenEvaluator mfaTokenEvaluator = http + .getSharedObject(MFATokenEvaluator.class); + if (mfaTokenEvaluator != null) { + httpSecurityRepository.setMFATokenEvaluator(mfaTokenEvaluator); + } http.setSharedObject(SecurityContextRepository.class, httpSecurityRepository); } diff --git a/core/src/main/java/org/springframework/security/authentication/AuthenticationTrustResolverImpl.java b/core/src/main/java/org/springframework/security/authentication/AuthenticationTrustResolverImpl.java index 53a9aef58bc..855cc4a1f54 100644 --- a/core/src/main/java/org/springframework/security/authentication/AuthenticationTrustResolverImpl.java +++ b/core/src/main/java/org/springframework/security/authentication/AuthenticationTrustResolverImpl.java @@ -36,6 +36,8 @@ public class AuthenticationTrustResolverImpl implements AuthenticationTrustResol private Class anonymousClass = AnonymousAuthenticationToken.class; private Class rememberMeClass = RememberMeAuthenticationToken.class; + private MFATokenEvaluator mfaTokenEvaluator = new MFATokenEvaluatorImpl(); + // ~ Methods // ======================================================================================================== @@ -52,6 +54,10 @@ public boolean isAnonymous(Authentication authentication) { return false; } + if (mfaTokenEvaluator != null && mfaTokenEvaluator.isMultiFactorAuthentication(authentication)) { + return true; + } + return anonymousClass.isAssignableFrom(authentication.getClass()); } @@ -70,4 +76,8 @@ public void setAnonymousClass(Class anonymousClass) { public void setRememberMeClass(Class rememberMeClass) { this.rememberMeClass = rememberMeClass; } + + public void setMFATokenEvaluator(MFATokenEvaluator mfaTokenEvaluator){ + this.mfaTokenEvaluator = mfaTokenEvaluator; + } } diff --git a/core/src/main/java/org/springframework/security/authentication/MFATokenEvaluator.java b/core/src/main/java/org/springframework/security/authentication/MFATokenEvaluator.java new file mode 100644 index 00000000000..cd64dfbc121 --- /dev/null +++ b/core/src/main/java/org/springframework/security/authentication/MFATokenEvaluator.java @@ -0,0 +1,51 @@ +/* + * Copyright 2002-2018 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.authentication; + +import org.springframework.security.core.Authentication; + +/** + * Evaluates Authentication tokens + * + * @author Yoshikazu Nojima + */ +public interface MFATokenEvaluator { + + /** + * Indicates whether the passed Authentication token represents a + * user in the middle of multi factor authentication process. + * + * @param authentication to test (may be null in which case the method + * will always return false) + * + * @return true the passed authentication token represented a principal + * in the middle of multi factor authentication process, false otherwise + */ + boolean isMultiFactorAuthentication(Authentication authentication); + + /** + * Indicates whether the principal associated with the Authentication + * token is allowed to login with only single factor. + * + * @param authentication to test (may be null in which case the method + * will always return false) + * + * @return true the principal associated with thepassed authentication + * token is allowed to login with only single factor, false otherwise + */ + boolean isSingleFactorAuthenticationAllowed(Authentication authentication); +} diff --git a/core/src/main/java/org/springframework/security/authentication/MFATokenEvaluatorImpl.java b/core/src/main/java/org/springframework/security/authentication/MFATokenEvaluatorImpl.java new file mode 100644 index 00000000000..acc98967e9b --- /dev/null +++ b/core/src/main/java/org/springframework/security/authentication/MFATokenEvaluatorImpl.java @@ -0,0 +1,82 @@ +/* + * Copyright 2002-2018 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.authentication; + +import org.springframework.security.core.Authentication; +import org.springframework.security.core.userdetails.MFAUserDetails; + +/** + * Basic implementation of {@link MFATokenEvaluator}. + *

+ * Makes trust decisions based on whether the passed Authentication is an + * instance of a defined class. + *

+ * If {@link #multiFactorClass} is null, the + * corresponding method will always return false. + * + * @author Yoshikazu Nojima + */ +public class MFATokenEvaluatorImpl implements MFATokenEvaluator { + + private Class multiFactorClass = MultiFactorAuthenticationToken.class; + private boolean singleFactorAuthenticationAllowed = true; + + @Override + public boolean isMultiFactorAuthentication(Authentication authentication) { + if ((multiFactorClass == null) || (authentication == null)) { + return false; + } + + return multiFactorClass.isAssignableFrom(authentication.getClass()); + } + + @Override + public boolean isSingleFactorAuthenticationAllowed(Authentication authentication) { + if (singleFactorAuthenticationAllowed && authentication.getPrincipal() instanceof MFAUserDetails) { + MFAUserDetails webAuthnUserDetails = (MFAUserDetails) authentication.getPrincipal(); + return webAuthnUserDetails.isSingleFactorAuthenticationAllowed(); + } + return false; + } + + Class getMultiFactorClass() { + return multiFactorClass; + } + + public void setMultiFactorClass(Class multiFactorClass) { + this.multiFactorClass = multiFactorClass; + } + + /** + * Check if single factor authentication is allowed + * + * @return true if single factor authentication is allowed + */ + public boolean isSingleFactorAuthenticationAllowed() { + return singleFactorAuthenticationAllowed; + } + + /** + * Set single factor authentication is allowed + * + * @param singleFactorAuthenticationAllowed true if single factor authentication is allowed + */ + public void setSingleFactorAuthenticationAllowed(boolean singleFactorAuthenticationAllowed) { + this.singleFactorAuthenticationAllowed = singleFactorAuthenticationAllowed; + } + +} diff --git a/core/src/main/java/org/springframework/security/authentication/MultiFactorAuthenticationProvider.java b/core/src/main/java/org/springframework/security/authentication/MultiFactorAuthenticationProvider.java new file mode 100644 index 00000000000..b9ecfb5117f --- /dev/null +++ b/core/src/main/java/org/springframework/security/authentication/MultiFactorAuthenticationProvider.java @@ -0,0 +1,97 @@ +/* + * Copyright 2002-2018 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.authentication; + +import org.springframework.context.support.MessageSourceAccessor; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.SpringSecurityMessageSource; +import org.springframework.util.Assert; + +import java.util.Collections; + +/** + * An {@link AuthenticationProvider} implementation for the first factor(step) of multi factor authentication. + * Authentication itself is delegated to another {@link AuthenticationProvider}. + * + * @author Yoshikazu Nojima + */ +public class MultiFactorAuthenticationProvider implements AuthenticationProvider { + + + // ~ Instance fields + // ================================================================================================ + protected MessageSourceAccessor messages = SpringSecurityMessageSource.getAccessor(); + + /** + * {@link AuthenticationProvider} to be delegated + */ + private AuthenticationProvider authenticationProvider; + private MFATokenEvaluator mfaTokenEvaluator; + + /** + * Constructor + * + * @param authenticationProvider {@link AuthenticationProvider} to be delegated + */ + public MultiFactorAuthenticationProvider(AuthenticationProvider authenticationProvider, MFATokenEvaluator mfaTokenEvaluator) { + Assert.notNull(authenticationProvider, "authenticationProvider must be set"); + Assert.notNull(mfaTokenEvaluator, "mfaTokenEvaluator must be set"); + this.authenticationProvider = authenticationProvider; + this.mfaTokenEvaluator = mfaTokenEvaluator; + } + + /** + * {@inheritDoc} + */ + @Override + public Authentication authenticate(Authentication authentication) { + if (!supports(authentication.getClass())) { + throw new IllegalArgumentException("Not supported AuthenticationToken " + authentication.getClass() + " was attempted"); + } + + Authentication result = authenticationProvider.authenticate(authentication); + + if (mfaTokenEvaluator.isSingleFactorAuthenticationAllowed(result)) { + return result; + } + + return new MultiFactorAuthenticationToken( + result.getPrincipal(), + result.getCredentials(), + Collections.emptyList() // result.getAuthorities() is not used as not to inherit authorities from result + ); + } + + /** + * {@inheritDoc} + */ + @Override + public boolean supports(Class authentication) { + return authenticationProvider.supports(authentication); + } + + /** + * {@link AuthenticationProvider} to be delegated + */ + public AuthenticationProvider getAuthenticationProvider() { + return authenticationProvider; + } + + public MFATokenEvaluator getMFATokenEvaluator() { + return mfaTokenEvaluator; + } +} diff --git a/core/src/main/java/org/springframework/security/authentication/MultiFactorAuthenticationToken.java b/core/src/main/java/org/springframework/security/authentication/MultiFactorAuthenticationToken.java new file mode 100644 index 00000000000..f0977505b0c --- /dev/null +++ b/core/src/main/java/org/springframework/security/authentication/MultiFactorAuthenticationToken.java @@ -0,0 +1,64 @@ +/* + * Copyright 2002-2018 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.authentication; + +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.SpringSecurityCoreVersion; + +import java.util.Collection; + +/** + * Represents a principal in the middle of multi factor (step) authentication + * + * @author Yoshikazu Nojima + */ +public class MultiFactorAuthenticationToken extends AbstractAuthenticationToken { + + private static final long serialVersionUID = SpringSecurityCoreVersion.SERIAL_VERSION_UID; + + private Object principal; + private Object credentials; + + // ~ Constructors + // =================================================================================================== + public MultiFactorAuthenticationToken(Object principal, Object credentials, Collection authorities) { + super(authorities); + this.principal = principal; + this.credentials = credentials; + setAuthenticated(true); + } + + // ~ Methods + // ======================================================================================================== + + @Override + public Object getPrincipal() { + return principal; + } + + @Override + public Object getCredentials() { + return credentials; + } + + @Override + public void eraseCredentials() { + super.eraseCredentials(); + credentials = null; + } + +} diff --git a/core/src/main/java/org/springframework/security/core/userdetails/MFAUserDetails.java b/core/src/main/java/org/springframework/security/core/userdetails/MFAUserDetails.java new file mode 100644 index 00000000000..0125c7decaf --- /dev/null +++ b/core/src/main/java/org/springframework/security/core/userdetails/MFAUserDetails.java @@ -0,0 +1,29 @@ +/* + * Copyright 2002-2018 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.core.userdetails; + +import org.springframework.security.core.userdetails.UserDetails; + +/** + * A specialized {@link UserDetails} to indicate a user allows single factor authentication + * @author Yoshikazu Nojima + */ +public interface MFAUserDetails extends UserDetails { + + boolean isSingleFactorAuthenticationAllowed(); + +} diff --git a/gradle/dependency-management.gradle b/gradle/dependency-management.gradle index a33638468b4..926f196c32c 100644 --- a/gradle/dependency-management.gradle +++ b/gradle/dependency-management.gradle @@ -21,6 +21,7 @@ dependencyManagement { dependencies { dependency 'cglib:cglib-nodep:3.3.0' dependency 'com.squareup.okhttp3:mockwebserver:3.14.2' + dependency 'com.webauthn4j:webauthn4j-test:0.9.13.RELEASE' dependency 'opensymphony:sitemesh:2.4.2' dependency 'org.gebish:geb-spock:0.10.0' dependency 'org.jasig.cas:cas-server-webapp:4.2.7' @@ -63,6 +64,7 @@ dependencyManagement { dependency 'com.sun.xml.bind:jaxb-core:2.3.0.1' dependency 'com.sun.xml.bind:jaxb-impl:2.3.2' dependency 'com.unboundid:unboundid-ldapsdk:4.0.11' + dependency 'com.webauthn4j:webauthn4j-core:0.9.13.RELEASE' dependency 'com.vaadin.external.google:android-json:0.0.20131108.vaadin1' dependency 'commons-cli:commons-cli:1.4' dependency 'commons-codec:commons-codec:1.13' diff --git a/samples/javaconfig/webauthn/spring-security-samples-javaconfig-webauthn.gradle b/samples/javaconfig/webauthn/spring-security-samples-javaconfig-webauthn.gradle new file mode 100644 index 00000000000..3f0a64ea0f9 --- /dev/null +++ b/samples/javaconfig/webauthn/spring-security-samples-javaconfig-webauthn.gradle @@ -0,0 +1,17 @@ +plugins { + id "io.spring.convention.spring-sample-boot" +} + +dependencies { + compile project(':spring-security-webauthn') + compile('org.springframework.boot:spring-boot-starter-web') + + compile("org.slf4j:jcl-over-slf4j") + compile('ch.qos.logback:logback-classic') + compile('org.thymeleaf:thymeleaf-spring5') + + compile('org.webjars:bootstrap:4.1.1') + compile('org.webjars:jquery:3.3.1') + compile('org.webjars:font-awesome:5.8.2') + +} diff --git a/samples/javaconfig/webauthn/src/main/java/org/springframework/security/webauthn/sample/SampleWebApplication.java b/samples/javaconfig/webauthn/src/main/java/org/springframework/security/webauthn/sample/SampleWebApplication.java new file mode 100644 index 00000000000..f64b5b95ee5 --- /dev/null +++ b/samples/javaconfig/webauthn/src/main/java/org/springframework/security/webauthn/sample/SampleWebApplication.java @@ -0,0 +1,34 @@ +/* + * Copyright 2002-2019 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.webauthn.sample; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.context.annotation.ComponentScan; + +/** + * SampleWebApplication + */ +@SpringBootApplication +@ComponentScan("org.springframework.security.webauthn.sample.app") +@ComponentScan("org.springframework.security.webauthn.sample.domain") +public class SampleWebApplication { + + public static void main(String[] args) { + SpringApplication.run(SampleWebApplication.class, args); + } +} diff --git a/samples/javaconfig/webauthn/src/main/java/org/springframework/security/webauthn/sample/app/config/WebSecurityBeanConfig.java b/samples/javaconfig/webauthn/src/main/java/org/springframework/security/webauthn/sample/app/config/WebSecurityBeanConfig.java new file mode 100644 index 00000000000..4bab8c8f580 --- /dev/null +++ b/samples/javaconfig/webauthn/src/main/java/org/springframework/security/webauthn/sample/app/config/WebSecurityBeanConfig.java @@ -0,0 +1,88 @@ +/* + * Copyright 2002-2019 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.webauthn.sample.app.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.authentication.dao.DaoAuthenticationProvider; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.webauthn.*; +import org.springframework.security.webauthn.challenge.HttpSessionWebAuthnChallengeRepository; +import org.springframework.security.webauthn.challenge.WebAuthnChallengeRepository; +import org.springframework.security.webauthn.server.EffectiveRpIdProvider; +import org.springframework.security.webauthn.server.WebAuthnServerPropertyProvider; +import org.springframework.security.webauthn.server.WebAuthnServerPropertyProviderImpl; +import org.springframework.security.webauthn.userdetails.InMemoryWebAuthnAndPasswordUserDetailsManager; +import org.springframework.security.webauthn.userdetails.WebAuthnUserDetailsService; + +@Configuration +public class WebSecurityBeanConfig { + + @Bean + public WebAuthnServerPropertyProvider webAuthnServerPropertyProvider(EffectiveRpIdProvider effectiveRpIdProvider, WebAuthnChallengeRepository challengeRepository){ + return new WebAuthnServerPropertyProviderImpl(effectiveRpIdProvider, challengeRepository); + } + + @Bean + public WebAuthnChallengeRepository webAuthnChallengeRepository(){ + return new HttpSessionWebAuthnChallengeRepository(); + } + + @Bean + public InMemoryWebAuthnAndPasswordUserDetailsManager webAuthnUserDetailsService(){ + return new InMemoryWebAuthnAndPasswordUserDetailsManager(); + } + + @Bean + public WebAuthnOptionWebHelper webAuthnOptionWebHelper(WebAuthnChallengeRepository challengeRepository, WebAuthnUserDetailsService userDetailsService){ + return new WebAuthnOptionWebHelper(challengeRepository, userDetailsService); + } + + @Bean + public WebAuthnManager webAuthnAuthenticationManager(){ + return new WebAuthn4JWebAuthnManager(); + } + + @Bean + public WebAuthnDataConverter webAuthnDataConverter(){ + return new WebAuthnDataConverter(); + } + + @Bean + public WebAuthnRegistrationRequestValidator webAuthnRegistrationRequestValidator(WebAuthnManager webAuthnManager, WebAuthnServerPropertyProvider webAuthnServerPropertyProvider){ + return new WebAuthnRegistrationRequestValidator(webAuthnManager, webAuthnServerPropertyProvider); + } + + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } + + // Not to register DaoAuthenticationProvider to ProviderManager, + // initialize DaoAuthenticationProvider manually instead of using DaoAuthenticationConfigurer. + @Bean + public DaoAuthenticationProvider daoAuthenticationProvider(PasswordEncoder passwordEncoder, UserDetailsService userDetailsService) { + DaoAuthenticationProvider daoAuthenticationProvider = new DaoAuthenticationProvider(); + daoAuthenticationProvider.setPasswordEncoder(passwordEncoder); + daoAuthenticationProvider.setUserDetailsService(userDetailsService); + return daoAuthenticationProvider; + } + +} diff --git a/samples/javaconfig/webauthn/src/main/java/org/springframework/security/webauthn/sample/app/config/WebSecurityConfig.java b/samples/javaconfig/webauthn/src/main/java/org/springframework/security/webauthn/sample/app/config/WebSecurityConfig.java new file mode 100644 index 00000000000..f087847197f --- /dev/null +++ b/samples/javaconfig/webauthn/src/main/java/org/springframework/security/webauthn/sample/app/config/WebSecurityConfig.java @@ -0,0 +1,110 @@ +/* + * Copyright 2002-2019 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.webauthn.sample.app.config; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; +import org.springframework.security.authentication.dao.DaoAuthenticationProvider; +import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder; +import org.springframework.security.config.annotation.authentication.configurers.mfa.MultiFactorAuthenticationProviderConfigurer; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.builders.WebSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter; +import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler; +import org.springframework.security.web.csrf.CookieCsrfTokenRepository; +import org.springframework.security.webauthn.WebAuthnManager; +import org.springframework.security.webauthn.authenticator.WebAuthnAuthenticatorService; +import org.springframework.security.webauthn.config.configurers.WebAuthnAuthenticationProviderConfigurer; +import org.springframework.security.webauthn.userdetails.WebAuthnUserDetailsService; + +import static org.springframework.security.webauthn.config.configurers.WebAuthnLoginConfigurer.webAuthnLogin; + + +/** + * Security Configuration + */ +@Configuration +@Import(value = WebSecurityBeanConfig.class) +@EnableWebSecurity +public class WebSecurityConfig extends WebSecurityConfigurerAdapter { + + @Autowired + private DaoAuthenticationProvider daoAuthenticationProvider; + + @Autowired + private WebAuthnUserDetailsService userDetailsService; + + @Autowired + private WebAuthnAuthenticatorService authenticatorService; + + @Autowired + private WebAuthnManager webAuthnManager; + + @Override + public void configure(AuthenticationManagerBuilder builder) throws Exception { + builder.apply(new WebAuthnAuthenticationProviderConfigurer<>(userDetailsService, authenticatorService, webAuthnManager)); + builder.apply(new MultiFactorAuthenticationProviderConfigurer<>(daoAuthenticationProvider)); + } + + @Override + public void configure(WebSecurity web) { + // ignore static resources + web.ignoring().antMatchers( + "/favicon.ico", + "/webjars/**", + "/js/**", + "/css/**"); + } + + /** + * Configure SecurityFilterChain + */ + @Override + protected void configure(HttpSecurity http) throws Exception { + + // WebAuthn Login + http.apply(webAuthnLogin()) + .loginPage("/login") + .usernameParameter("username") + .passwordParameter("password") + .credentialIdParameter("credentialId") + .clientDataJSONParameter("clientDataJSON") + .authenticatorDataParameter("authenticatorData") + .signatureParameter("signature") + .clientExtensionsJSONParameter("clientExtensionsJSON") + .loginProcessingUrl("/login") + .successHandler(new SimpleUrlAuthenticationSuccessHandler("/dashboard")); + + // Logout + http.logout() + .logoutUrl("/logout"); + // Authorization + http.authorizeRequests() + .mvcMatchers("/").permitAll() + .mvcMatchers("/signup").permitAll() + .mvcMatchers("/login").permitAll() + .mvcMatchers("/h2-console/**").denyAll() + .anyRequest().fullyAuthenticated(); + + http.csrf().csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse()); + + } + + +} diff --git a/samples/javaconfig/webauthn/src/main/java/org/springframework/security/webauthn/sample/app/web/AuthenticatorCreateForm.java b/samples/javaconfig/webauthn/src/main/java/org/springframework/security/webauthn/sample/app/web/AuthenticatorCreateForm.java new file mode 100644 index 00000000000..9201335d8c2 --- /dev/null +++ b/samples/javaconfig/webauthn/src/main/java/org/springframework/security/webauthn/sample/app/web/AuthenticatorCreateForm.java @@ -0,0 +1,69 @@ +/* + * Copyright 2002-2019 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.webauthn.sample.app.web; + +import javax.validation.Valid; +import javax.validation.constraints.NotNull; +import java.util.Set; + +public class AuthenticatorCreateForm { + + @NotNull + @Valid + private String clientDataJSON; + + @NotNull + @Valid + private String attestationObject; + + private Set transports; + + @NotNull + private String clientExtensions; + + public String getClientDataJSON() { + return clientDataJSON; + } + + public void setClientDataJSON(String clientDataJSON) { + this.clientDataJSON = clientDataJSON; + } + + public String getAttestationObject() { + return attestationObject; + } + + public void setAttestationObject(String attestationObject) { + this.attestationObject = attestationObject; + } + + public Set getTransports() { + return transports; + } + + public void setTransports(Set transports) { + this.transports = transports; + } + + public String getClientExtensions() { + return clientExtensions; + } + + public void setClientExtensions(String clientExtensions) { + this.clientExtensions = clientExtensions; + } +} diff --git a/samples/javaconfig/webauthn/src/main/java/org/springframework/security/webauthn/sample/app/web/UserCreateForm.java b/samples/javaconfig/webauthn/src/main/java/org/springframework/security/webauthn/sample/app/web/UserCreateForm.java new file mode 100644 index 00000000000..44cfc84450a --- /dev/null +++ b/samples/javaconfig/webauthn/src/main/java/org/springframework/security/webauthn/sample/app/web/UserCreateForm.java @@ -0,0 +1,84 @@ +/* + * Copyright 2002-2019 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.webauthn.sample.app.web; + + +import javax.validation.Valid; +import javax.validation.constraints.NotEmpty; +import javax.validation.constraints.NotNull; + +/** + * Form for User + */ +public class UserCreateForm { + + @NotNull + private String userHandle; + + @NotEmpty + private String username; + + @NotEmpty + private String password; + + @Valid + @NotNull + private AuthenticatorCreateForm authenticator; + + private boolean singleFactorAuthenticationAllowed; + + public String getUserHandle() { + return userHandle; + } + + public void setUserHandle(String userHandle) { + this.userHandle = userHandle; + } + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + public String getPassword() { + return password; + } + + public void setPassword(String password) { + this.password = password; + } + + public AuthenticatorCreateForm getAuthenticator() { + return authenticator; + } + + public void setAuthenticator(AuthenticatorCreateForm authenticator) { + this.authenticator = authenticator; + } + + public boolean isSingleFactorAuthenticationAllowed() { + return singleFactorAuthenticationAllowed; + } + + public void setSingleFactorAuthenticationAllowed(boolean singleFactorAuthenticationAllowed) { + this.singleFactorAuthenticationAllowed = singleFactorAuthenticationAllowed; + } + +} diff --git a/samples/javaconfig/webauthn/src/main/java/org/springframework/security/webauthn/sample/app/web/WebAuthnSampleController.java b/samples/javaconfig/webauthn/src/main/java/org/springframework/security/webauthn/sample/app/web/WebAuthnSampleController.java new file mode 100644 index 00000000000..f8c7afacd02 --- /dev/null +++ b/samples/javaconfig/webauthn/src/main/java/org/springframework/security/webauthn/sample/app/web/WebAuthnSampleController.java @@ -0,0 +1,189 @@ +/* + * Copyright 2002-2019 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.webauthn.sample.app.web; + +import com.webauthn4j.data.AuthenticatorTransport; +import com.webauthn4j.util.Base64UrlUtil; +import com.webauthn4j.util.UUIDUtil; +import com.webauthn4j.util.exception.WebAuthnException; +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.authentication.MultiFactorAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.webauthn.WebAuthnDataConverter; +import org.springframework.security.webauthn.WebAuthnOptionWebHelper; +import org.springframework.security.webauthn.WebAuthnRegistrationRequest; +import org.springframework.security.webauthn.WebAuthnRegistrationRequestValidator; +import org.springframework.security.webauthn.authenticator.WebAuthnAuthenticator; +import org.springframework.security.webauthn.authenticator.WebAuthnAuthenticatorImpl; +import org.springframework.security.webauthn.exception.WebAuthnAuthenticationException; +import org.springframework.security.webauthn.userdetails.InMemoryWebAuthnAndPasswordUserDetailsManager; +import org.springframework.security.webauthn.userdetails.WebAuthnAndPasswordUser; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.util.Base64Utils; +import org.springframework.validation.BindingResult; +import org.springframework.web.bind.annotation.ModelAttribute; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestMethod; + +import javax.servlet.http.HttpServletRequest; +import javax.validation.Valid; +import java.util.*; +import java.util.stream.Collectors; + +/** + * Login controller + */ +@SuppressWarnings("SameReturnValue") +@Controller +public class WebAuthnSampleController { + + private final Log logger = LogFactory.getLog(getClass()); + + private static final String REDIRECT_LOGIN = "redirect:/login"; + private static final String REDIRECT_SIGNUP = "redirect:/signup"; + + private static final String VIEW_SIGNUP_SIGNUP = "signup/signup"; + private static final String VIEW_LOGIN_LOGIN = "login/login"; + private static final String VIEW_LOGIN_AUTHENTICATOR_LOGIN = "login/authenticator-login"; + + private static final String VIEW_DASHBOARD_DASHBOARD = "dashboard/dashboard"; + + @Autowired + private InMemoryWebAuthnAndPasswordUserDetailsManager webAuthnUserDetailsService; + + @Autowired + private WebAuthnRegistrationRequestValidator registrationRequestValidator; + + @Autowired + private WebAuthnOptionWebHelper webAuthnOptionWebHelper; + + @Autowired + private WebAuthnDataConverter webAuthnDataConverter; + + @Autowired + private PasswordEncoder passwordEncoder; + + @ModelAttribute + public void addAttributes(Model model, HttpServletRequest request) { + model.addAttribute("webAuthnChallenge", webAuthnOptionWebHelper.getChallenge(request)); + model.addAttribute("webAuthnCredentialIds", webAuthnOptionWebHelper.getCredentialIds()); + } + + @RequestMapping(value = "/") + public String index(Model model) { + return REDIRECT_SIGNUP; + } + + @RequestMapping(value = "/dashboard") + public String dashboard(Model model) { + return VIEW_DASHBOARD_DASHBOARD; + } + + @RequestMapping(value = "/signup", method = RequestMethod.GET) + public String template(Model model) { + UserCreateForm userCreateForm = new UserCreateForm(); + UUID userHandle = UUID.randomUUID(); + String userHandleStr = java.util.Base64.getUrlEncoder().withoutPadding().encodeToString(UUIDUtil.convertUUIDToBytes(userHandle)); + userCreateForm.setUserHandle(userHandleStr); + model.addAttribute("userForm", userCreateForm); + return VIEW_SIGNUP_SIGNUP; + } + + @RequestMapping(value = "/signup", method = RequestMethod.POST) + public String create(HttpServletRequest request, @Valid @ModelAttribute("userForm") UserCreateForm userCreateForm, BindingResult result, Model model) { + + if (result.hasErrors()) { + return VIEW_SIGNUP_SIGNUP; + } + + WebAuthnRegistrationRequest webAuthnRegistrationRequest = new WebAuthnRegistrationRequest( + request, + userCreateForm.getAuthenticator().getClientDataJSON(), + userCreateForm.getAuthenticator().getAttestationObject(), + userCreateForm.getAuthenticator().getTransports(), + userCreateForm.getAuthenticator().getClientExtensions() + ); + try { + registrationRequestValidator.validate(webAuthnRegistrationRequest); + } + catch (WebAuthnException | WebAuthnAuthenticationException e){ + logger.debug("WebAuthn registration request validation failed.", e); + return VIEW_SIGNUP_SIGNUP; + } + + AuthenticatorCreateForm sourceAuthenticator = userCreateForm.getAuthenticator(); + + byte[] attestationObject = Base64UrlUtil.decode(sourceAuthenticator.getAttestationObject()); + byte[] authenticatorData = webAuthnDataConverter.extractAuthenticatorData(attestationObject); + byte[] attestedCredentialData = webAuthnDataConverter.extractAttestedCredentialData(authenticatorData); + byte[] credentialId = webAuthnDataConverter.extractCredentialId(attestedCredentialData); + long signCount = webAuthnDataConverter.extractSignCount(authenticatorData); + Set transports; + if (sourceAuthenticator.getTransports() == null) { + transports = null; + } + else { + transports = sourceAuthenticator.getTransports().stream() + .map(AuthenticatorTransport::create) + .collect(Collectors.toSet()); + } + + List authenticators = new ArrayList<>(); + WebAuthnAuthenticator authenticator = new WebAuthnAuthenticatorImpl( + credentialId, + null, + attestationObject, + signCount, + transports, + sourceAuthenticator.getClientExtensions()); + + authenticators.add(authenticator); + + byte[] userHandle = Base64Utils.decodeFromUrlSafeString(userCreateForm.getUserHandle()); + String username = userCreateForm.getUsername(); + String password = passwordEncoder.encode(userCreateForm.getPassword()); + List authorities = Collections.emptyList(); + boolean singleFactorAuthenticationAllowed = userCreateForm.isSingleFactorAuthenticationAllowed(); + WebAuthnAndPasswordUser user = new WebAuthnAndPasswordUser(userHandle, username, password, authenticators, singleFactorAuthenticationAllowed, authorities); + + + try { + webAuthnUserDetailsService.createUser(user); + } catch (IllegalArgumentException ex) { + return VIEW_SIGNUP_SIGNUP; + } + + return REDIRECT_LOGIN; + } + + @RequestMapping(value = "/login", method = RequestMethod.GET) + public String login() { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + if (authentication instanceof MultiFactorAuthenticationToken) { + return VIEW_LOGIN_AUTHENTICATOR_LOGIN; + } else { + return VIEW_LOGIN_LOGIN; + } + } + +} diff --git a/samples/javaconfig/webauthn/src/main/resources/static/css/tiny.css b/samples/javaconfig/webauthn/src/main/resources/static/css/tiny.css new file mode 100644 index 00000000000..4815ab563bb --- /dev/null +++ b/samples/javaconfig/webauthn/src/main/resources/static/css/tiny.css @@ -0,0 +1,44 @@ +/* + * Copyright 2002-2019 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. + */ + +.content-wrapper{ + max-width: 400px; + width: 100%; + padding: 15px; + margin: auto; +} + +.login-form input#username { + margin-bottom: -1px; + border-bottom-right-radius: 0; + border-bottom-left-radius: 0; +} +.login-form input#password { + margin-bottom: 10px; + border-top-left-radius: 0; + border-top-right-radius: 0; +} + +.signup-form input#username { + margin-bottom: 2em; +} +.signup-form input#password{ + margin-bottom: 2em; +} +.signup-form input#authenticator { + margin-bottom: 4em; +} + diff --git a/samples/javaconfig/webauthn/src/main/resources/static/js/base64url.js b/samples/javaconfig/webauthn/src/main/resources/static/js/base64url.js new file mode 100644 index 00000000000..ee540d04f68 --- /dev/null +++ b/samples/javaconfig/webauthn/src/main/resources/static/js/base64url.js @@ -0,0 +1,63 @@ +"use strict"; + +(function (exports) { + + var lookup = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; + var reverseLookup = new Uint8Array(256); + + for (var i = 0; i < lookup.length; i++) { + reverseLookup[lookup.charCodeAt(i)] = i; + } + + function decodeBase64url(base64url) { + var base64urlLength = base64url.length; + + var placeHolderLength = base64url.charAt(base64urlLength - 2) === '=' ? 2 : base64url.charAt(base64urlLength - 1) === '=' ? 1 : 0; + var bufferLength = (base64urlLength * 3 / 4) - placeHolderLength; + + var arrayBuffer = new ArrayBuffer(bufferLength); + var uint8Array = new Uint8Array(arrayBuffer); + + var j = 0; + for (var i = 0; i < base64urlLength; i+=4) { + var tmp0 = reverseLookup[base64url.charCodeAt(i)]; + var tmp1 = reverseLookup[base64url.charCodeAt(i+1)]; + var tmp2 = reverseLookup[base64url.charCodeAt(i+2)]; + var tmp3 = reverseLookup[base64url.charCodeAt(i+3)]; + + uint8Array[j++] = (tmp0 << 2) | (tmp1 >> 4); + uint8Array[j++] = ((tmp1 & 15) << 4) | (tmp2 >> 2); + uint8Array[j++] = ((tmp2 & 3) << 6) | (tmp3 & 63); + } + + return arrayBuffer; + } + + function encodeBase64url(arrayBuffer) { + var uint8Array = new Uint8Array(arrayBuffer); + var length = uint8Array.length; + var base64url = ""; + + for (var i = 0; i < length; i+=3) { + base64url += lookup[uint8Array[i] >> 2]; + base64url += lookup[((uint8Array[i] & 3) << 4) | (uint8Array[i + 1] >> 4)]; + base64url += lookup[((uint8Array[i + 1] & 15) << 2) | (uint8Array[i + 2] >> 6)]; + base64url += lookup[uint8Array[i + 2] & 63]; + } + + switch (length % 3) { + case 1: + base64url = base64url.substring(0, base64url.length - 2); + break; + case 2: + base64url = base64url.substring(0, base64url.length - 1); + break; + } + return base64url; + } + + exports.decodeBase64url = decodeBase64url; + exports.encodeBase64url = encodeBase64url; + +}(typeof exports === 'undefined' ? (this.base64url = {}) : exports)); + diff --git a/samples/javaconfig/webauthn/src/main/resources/static/js/webauthn.js b/samples/javaconfig/webauthn/src/main/resources/static/js/webauthn.js new file mode 100644 index 00000000000..d64375f06c0 --- /dev/null +++ b/samples/javaconfig/webauthn/src/main/resources/static/js/webauthn.js @@ -0,0 +1,149 @@ + +function createCredential(residentKeyRequirement){ + + var username = $("#username").val(); + var userHandle = $("#userHandle").val(); + var challenge = $("meta[name=webAuthnChallenge]").attr("content"); + var credentialIds = $("meta[name=webAuthnCredentialId]") + .map(function(i, element){ return $(element).attr("content")}) + .get(); + + var publicKeyCredentialCreationOptions = { + rp: { + name: "Spring Security WebAuthn Sample" + }, + user: { + id: base64url.decodeBase64url(userHandle), + name: username, + displayName: username + }, + challenge: base64url.decodeBase64url(challenge), + pubKeyCredParams: [ + { + "type": "public-key", + "alg": -7 //ES256 + }, + { + type: "public-key", + alg: -257 //RS256 + } + ], + excludeCredentials: credentialIds.map(function(credentialId){ + return { + type: "public-key", + id: base64url.decodeBase64url(credentialId) + } + }), + authenticatorSelection: { + requireResidentKey: residentKeyRequirement + }, + attestation: "none" + }; + + var credentialCreationOptions = { + publicKey: publicKeyCredentialCreationOptions + }; + + return navigator.credentials.create(credentialCreationOptions); +} + +function getCredential(userVerification){ + var challenge = $("meta[name=webAuthnChallenge]").attr("content"); + var credentialIds = $("meta[name=webAuthnCredentialId]") + .map(function(i, element){ return $(element).attr("content")}) + .get(); + var publicKeyCredentialRequestOptions = { + challenge: base64url.decodeBase64url(challenge), + allowCredentials: credentialIds.map(function(credentialId){ + return { + type: "public-key", + id: base64url.decodeBase64url(credentialId) + } + }), + userVerification: userVerification + }; + + var credentialRequestOptions = { + publicKey: publicKeyCredentialRequestOptions + }; + + return navigator.credentials.get(credentialRequestOptions); +} + +$(document).ready(function() { + + var dialog = $("#resident-key-requirement-dialog"); + + var onResidentKeyRequirementDialogClosing = function(residentKeyRequirement){ + createCredential(residentKeyRequirement).then(function (credential) { + console.log(credential); + $('#clientDataJSON').val(base64url.encodeBase64url(credential.response.clientDataJSON)); + $('#attestationObject').val(base64url.encodeBase64url(credential.response.attestationObject)); + $('#clientExtensions').val(JSON.stringify(credential.getClientExtensionResults())); + $('#authenticator').text('Authenticator registered'); + $('#authenticator').prop('disabled', true); + $('#submit').prop('disabled', false); + dialog.modal('hide'); + }).catch(function (e) { + console.error("Error:%s, Message:%s", e.name, e.message); + dialog.modal('hide'); + }); + }; + + $('#resident-key-requirement-dialog-yes').click(function () { + onResidentKeyRequirementDialogClosing(true); + }); + $('#resident-key-requirement-dialog-no').click(function () { + onResidentKeyRequirementDialogClosing(false); + }); + $('#resident-key-requirement-dialog-close').click(function () { + dialog.modal('hide'); + }); + + $('#authenticator').click(function(){ + dialog.modal('show'); + }); + + $('#fast-login').click(function(){ + getCredential("required").then(function (credential) { + console.log(credential); + $("#credentialId").val(credential.id); + $("#clientDataJSON").val(base64url.encodeBase64url(credential.response.clientDataJSON)); + $("#authenticatorData").val(base64url.encodeBase64url(credential.response.authenticatorData)); + $("#signature").val(base64url.encodeBase64url(credential.response.signature)); + $("#clientExtensions").val(JSON.stringify(credential.getClientExtensionResults())); + $('#login-form').submit(); + }).catch(function (e) { + console.error("Error:%s, Message:%s", e.name, e.message); + }); + return false; + }); + $('#retry').click(function(){ + getCredential("preferred").then(function (credential) { + console.log(credential); + $("#credentialId").val(credential.id); + $("#clientDataJSON").val(base64url.encodeBase64url(credential.response.clientDataJSON)); + $("#authenticatorData").val(base64url.encodeBase64url(credential.response.authenticatorData)); + $("#signature").val(base64url.encodeBase64url(credential.response.signature)); + $("#clientExtensions").val(JSON.stringify(credential.getClientExtensionResults())); + $('#login-form').submit(); + }).catch(function (e) { + console.error("Error:%s, Message:%s", e.name, e.message); + }); + return false; + }); + + if($('#login-authenticator-login-view').length>0){ + return getCredential("preferred").then(function (credential) { + console.log(credential); + $("#credentialId").val(credential.id); + $("#clientDataJSON").val(base64url.encodeBase64url(credential.response.clientDataJSON)); + $("#authenticatorData").val(base64url.encodeBase64url(credential.response.authenticatorData)); + $("#signature").val(base64url.encodeBase64url(credential.response.signature)); + $("#clientExtensions").val(JSON.stringify(credential.getClientExtensionResults())); + $('#login-form').submit(); + }).catch(function (e) { + console.error("Error:%s, Message:%s", e.name, e.message); + }); + } +}); diff --git a/samples/javaconfig/webauthn/src/main/resources/templates/dashboard/dashboard.html b/samples/javaconfig/webauthn/src/main/resources/templates/dashboard/dashboard.html new file mode 100644 index 00000000000..1d7d3c1e143 --- /dev/null +++ b/samples/javaconfig/webauthn/src/main/resources/templates/dashboard/dashboard.html @@ -0,0 +1,44 @@ + + + + + Spring Security WebAuthn Sample + + + + + + + + + + + + + + + + + + + + + +

+

Spring Security WebAuthn Sample

+ +

Dashboard

+ +

Login success

+ +
+
+ +
+
+ +
+ + + + diff --git a/samples/javaconfig/webauthn/src/main/resources/templates/login/authenticator-login.html b/samples/javaconfig/webauthn/src/main/resources/templates/login/authenticator-login.html new file mode 100644 index 00000000000..de4ab3d430e --- /dev/null +++ b/samples/javaconfig/webauthn/src/main/resources/templates/login/authenticator-login.html @@ -0,0 +1,61 @@ + + + + + Spring Security WebAuthn Sample + + + + + + + + + + + + + + + + + + + + + +
+

Spring Security WebAuthn Sample

+ +

Login

+ + + +
+
+ +
+ +
+ +
+ + + + + + +
+ + +
+
+ +
+
+ +
+ + + + diff --git a/samples/javaconfig/webauthn/src/main/resources/templates/login/login.html b/samples/javaconfig/webauthn/src/main/resources/templates/login/login.html new file mode 100644 index 00000000000..db6dd1a9473 --- /dev/null +++ b/samples/javaconfig/webauthn/src/main/resources/templates/login/login.html @@ -0,0 +1,79 @@ + + + + + Spring Security WebAuthn Sample + + + + + + + + + + + + + + + + + + + + + +
+

Spring Security WebAuthn Sample

+ +

Login

+ + + + + + + Sign up + +
+ + + + diff --git a/samples/javaconfig/webauthn/src/main/resources/templates/signup/signup.html b/samples/javaconfig/webauthn/src/main/resources/templates/signup/signup.html new file mode 100644 index 00000000000..df822ef7e2b --- /dev/null +++ b/samples/javaconfig/webauthn/src/main/resources/templates/signup/signup.html @@ -0,0 +1,108 @@ + + + + + Spring Security WebAuthn Sample + + + + + + + + + + + + + + + + + + + + + +
+

Spring Security WebAuthn Sample

+ +

Sign up

+ + + + + + Login + +
+ + +
+ +
+ + + diff --git a/web/src/main/java/org/springframework/security/web/access/ExceptionTranslationFilter.java b/web/src/main/java/org/springframework/security/web/access/ExceptionTranslationFilter.java index 3e3345250c4..23ee8152181 100644 --- a/web/src/main/java/org/springframework/security/web/access/ExceptionTranslationFilter.java +++ b/web/src/main/java/org/springframework/security/web/access/ExceptionTranslationFilter.java @@ -19,6 +19,8 @@ import org.springframework.security.authentication.AuthenticationTrustResolver; import org.springframework.security.authentication.AuthenticationTrustResolverImpl; import org.springframework.security.authentication.InsufficientAuthenticationException; +import org.springframework.security.authentication.MFATokenEvaluator; +import org.springframework.security.authentication.MFATokenEvaluatorImpl; import org.springframework.security.core.Authentication; import org.springframework.security.core.AuthenticationException; import org.springframework.security.core.SpringSecurityMessageSource; @@ -81,6 +83,7 @@ public class ExceptionTranslationFilter extends GenericFilterBean { private AccessDeniedHandler accessDeniedHandler = new AccessDeniedHandlerImpl(); private AuthenticationEntryPoint authenticationEntryPoint; private AuthenticationTrustResolver authenticationTrustResolver = new AuthenticationTrustResolverImpl(); + private MFATokenEvaluator mfaTokenEvaluator = new MFATokenEvaluatorImpl(); private ThrowableAnalyzer throwableAnalyzer = new DefaultThrowableAnalyzer(); private RequestCache requestCache = new HttpSessionRequestCache(); @@ -163,6 +166,8 @@ protected AuthenticationTrustResolver getAuthenticationTrustResolver() { return authenticationTrustResolver; } + protected MFATokenEvaluator getMFATokenEvaluator(){ return mfaTokenEvaluator; } + private void handleSpringSecurityException(HttpServletRequest request, HttpServletResponse response, FilterChain chain, RuntimeException exception) throws IOException, ServletException { @@ -204,9 +209,15 @@ else if (exception instanceof AccessDeniedException) { protected void sendStartAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, AuthenticationException reason) throws ServletException, IOException { - // SEC-112: Clear the SecurityContextHolder's Authentication, as the - // existing Authentication is no longer considered valid - SecurityContextHolder.getContext().setAuthentication(null); + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + if (mfaTokenEvaluator.isMultiFactorAuthentication(authentication)) { + // no-op if in the middle of multi step authentication + } + else { + // SEC-112: Clear the SecurityContextHolder's Authentication, as the + // existing Authentication is no longer considered valid + SecurityContextHolder.getContext().setAuthentication(null); + } requestCache.saveRequest(request, response); logger.debug("Calling Authentication entry point."); authenticationEntryPoint.commence(request, response, reason); @@ -224,6 +235,12 @@ public void setAuthenticationTrustResolver( this.authenticationTrustResolver = authenticationTrustResolver; } + public void setMFATokenEvaluator( + MFATokenEvaluator mfaTokenEvaluator){ + Assert.notNull(mfaTokenEvaluator, "mfaTokenEvaluator must not be null"); + this.mfaTokenEvaluator = mfaTokenEvaluator; + } + public void setThrowableAnalyzer(ThrowableAnalyzer throwableAnalyzer) { Assert.notNull(throwableAnalyzer, "throwableAnalyzer must not be null"); this.throwableAnalyzer = throwableAnalyzer; diff --git a/web/src/main/java/org/springframework/security/web/context/HttpSessionSecurityContextRepository.java b/web/src/main/java/org/springframework/security/web/context/HttpSessionSecurityContextRepository.java index 85f90e3f590..4c88a4e25b2 100644 --- a/web/src/main/java/org/springframework/security/web/context/HttpSessionSecurityContextRepository.java +++ b/web/src/main/java/org/springframework/security/web/context/HttpSessionSecurityContextRepository.java @@ -29,6 +29,8 @@ import org.springframework.core.annotation.AnnotationUtils; import org.springframework.security.authentication.AuthenticationTrustResolver; import org.springframework.security.authentication.AuthenticationTrustResolverImpl; +import org.springframework.security.authentication.MFATokenEvaluator; +import org.springframework.security.authentication.MFATokenEvaluatorImpl; import org.springframework.security.core.Authentication; import org.springframework.security.core.Transient; import org.springframework.security.core.context.SecurityContext; @@ -97,6 +99,7 @@ public class HttpSessionSecurityContextRepository implements SecurityContextRepo private String springSecurityContextKey = SPRING_SECURITY_CONTEXT_KEY; private AuthenticationTrustResolver trustResolver = new AuthenticationTrustResolverImpl(); + private MFATokenEvaluator mfaTokenEvaluator = new MFATokenEvaluatorImpl(); /** * Gets the security context for the current request (if available) and returns it. @@ -331,8 +334,8 @@ final class SaveToSessionResponseWrapper extends /** * Stores the supplied security context in the session (if available) and if it * has changed since it was set at the start of the request. If the - * AuthenticationTrustResolver identifies the current user as anonymous, then the - * context will not be stored. + * AuthenticationTrustResolver identifies the current user as anonymous, but not + * in the middle of multi factor authentication, then the context will not be stored. * * @param context the context object obtained from the SecurityContextHolder after * the request has been processed by the filter chain. @@ -346,7 +349,7 @@ protected void saveContext(SecurityContext context) { HttpSession httpSession = request.getSession(false); // See SEC-776 - if (authentication == null || trustResolver.isAnonymous(authentication)) { + if (authentication == null || trustResolver.isAnonymous(authentication) && !mfaTokenEvaluator.isMultiFactorAuthentication(authentication)) { if (logger.isDebugEnabled()) { logger.debug("SecurityContext is empty or contents are anonymous - context will not be stored in HttpSession."); } @@ -455,4 +458,16 @@ public void setTrustResolver(AuthenticationTrustResolver trustResolver) { Assert.notNull(trustResolver, "trustResolver cannot be null"); this.trustResolver = trustResolver; } + + /** + * Sets the {@link MFATokenEvaluator} to be used. The default is + * {@link MFATokenEvaluatorImpl}. + * + * @param mfaTokenEvaluator the {@link MFATokenEvaluator} to use. Cannot be + * null. + */ + public void setMFATokenEvaluator(MFATokenEvaluator mfaTokenEvaluator) { + Assert.notNull(mfaTokenEvaluator, "mfaTokenEvaluator cannot be null"); + this.mfaTokenEvaluator = mfaTokenEvaluator; + } } diff --git a/web/src/test/java/org/springframework/security/web/access/ExceptionTranslationFilterTests.java b/web/src/test/java/org/springframework/security/web/access/ExceptionTranslationFilterTests.java index c26c3b98b4d..5b83d6089d4 100644 --- a/web/src/test/java/org/springframework/security/web/access/ExceptionTranslationFilterTests.java +++ b/web/src/test/java/org/springframework/security/web/access/ExceptionTranslationFilterTests.java @@ -43,6 +43,7 @@ import org.springframework.security.authentication.AnonymousAuthenticationToken; import org.springframework.security.authentication.AuthenticationTrustResolverImpl; import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.security.authentication.MultiFactorAuthenticationToken; import org.springframework.security.authentication.RememberMeAuthenticationToken; import org.springframework.security.core.authority.AuthorityUtils; import org.springframework.security.core.context.SecurityContext; @@ -111,6 +112,40 @@ public void testAccessDeniedWhenAnonymous() throws Exception { assertThat(getSavedRequestUrl(request)).isEqualTo("http://localhost/mycontext/secure/page.html"); } + @Test + public void testAccessDeniedWhenMultiFactorAuthentication() throws Exception { + // Setup our HTTP request + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setServletPath("/secure/page.html"); + request.setServerPort(80); + request.setScheme("http"); + request.setServerName("www.example.com"); + request.setContextPath("/mycontext"); + request.setRequestURI("/mycontext/secure/page.html"); + + // Setup the FilterChain to thrown an access denied exception + FilterChain fc = mock(FilterChain.class); + doThrow(new AccessDeniedException("")).when(fc).doFilter( + any(HttpServletRequest.class), any(HttpServletResponse.class)); + + // Setup SecurityContextHolder, as filter needs to check if user is + // anonymous + SecurityContextHolder.getContext().setAuthentication( + new MultiFactorAuthenticationToken("ignored", "ignored", AuthorityUtils + .createAuthorityList("IGNORED"))); + + // Test + ExceptionTranslationFilter filter = new ExceptionTranslationFilter(mockEntryPoint); + filter.setAuthenticationTrustResolver(new AuthenticationTrustResolverImpl()); + assertThat(filter.getAuthenticationTrustResolver()).isNotNull(); + + MockHttpServletResponse response = new MockHttpServletResponse(); + filter.doFilter(request, response, fc); + assertThat(response.getRedirectedUrl()).isEqualTo("/mycontext/login.jsp"); + assertThat(getSavedRequestUrl(request)).isEqualTo("http://www.example.com/mycontext/secure/page.html"); + } + + @Test public void testAccessDeniedWithRememberMe() throws Exception { // Setup our HTTP request diff --git a/webauthn/spring-security-webauthn.gradle b/webauthn/spring-security-webauthn.gradle new file mode 100644 index 00000000000..44933d9afcb --- /dev/null +++ b/webauthn/spring-security-webauthn.gradle @@ -0,0 +1,28 @@ +apply plugin: 'io.spring.convention.spring-module' + +dependencies { + compile project(':spring-security-core') + compile project(':spring-security-config') + compile project(':spring-security-web') + compile springCoreDependency + compile("org.springframework:spring-core") + compile("org.springframework:spring-context") + compile("org.springframework:spring-aop") + compile("org.springframework:spring-jdbc") + compile("org.springframework:spring-web") + + compile("com.webauthn4j:webauthn4j-core") + + provided 'javax.servlet:javax.servlet-api' + + compile project(':spring-security-test') + testCompile("com.webauthn4j:webauthn4j-test") + testCompile("org.skyscreamer:jsonassert") + testCompile("org.springframework:spring-webmvc") + testCompile('junit:junit') + testCompile('org.mockito:mockito-core') + testCompile('org.assertj:assertj-core') + + + testRuntime 'org.hsqldb:hsqldb' +} diff --git a/webauthn/src/main/java/org/springframework/security/webauthn/WebAuthn4JWebAuthnManager.java b/webauthn/src/main/java/org/springframework/security/webauthn/WebAuthn4JWebAuthnManager.java new file mode 100644 index 00000000000..906b4e76cf8 --- /dev/null +++ b/webauthn/src/main/java/org/springframework/security/webauthn/WebAuthn4JWebAuthnManager.java @@ -0,0 +1,193 @@ +/* + * Copyright 2002-2019 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.webauthn; + +import com.webauthn4j.authenticator.Authenticator; +import com.webauthn4j.authenticator.AuthenticatorImpl; +import com.webauthn4j.converter.util.CborConverter; +import com.webauthn4j.data.AuthenticatorTransport; +import com.webauthn4j.data.WebAuthnAuthenticationContext; +import com.webauthn4j.data.WebAuthnRegistrationContext; +import com.webauthn4j.data.attestation.AttestationObject; +import com.webauthn4j.data.client.Origin; +import com.webauthn4j.data.client.challenge.DefaultChallenge; +import com.webauthn4j.server.ServerProperty; +import com.webauthn4j.validator.WebAuthnAuthenticationContextValidator; +import com.webauthn4j.validator.WebAuthnRegistrationContextValidator; +import org.springframework.security.webauthn.authenticator.WebAuthnAuthenticator; +import org.springframework.util.Assert; + +import javax.servlet.ServletRequest; +import javax.servlet.http.HttpServletRequest; +import java.util.Collections; +import java.util.Set; +import java.util.stream.Collectors; + +public class WebAuthn4JWebAuthnManager implements WebAuthnManager { + + // ~ Instance fields + // ================================================================================================ + private WebAuthnRegistrationContextValidator registrationContextValidator; + private WebAuthnAuthenticationContextValidator authenticationContextValidator; + + private String rpId; + + private CborConverter cborConverter; + + public WebAuthn4JWebAuthnManager( + WebAuthnRegistrationContextValidator registrationContextValidator, + WebAuthnAuthenticationContextValidator authenticationContextValidator, + WebAuthnDataConverter webAuthnDataConverter) { + this.registrationContextValidator = registrationContextValidator; + this.authenticationContextValidator = authenticationContextValidator; + this.cborConverter = new CborConverter(webAuthnDataConverter.getJsonMapper(), webAuthnDataConverter.getCborMapper()); + } + + public WebAuthn4JWebAuthnManager( + WebAuthnRegistrationContextValidator registrationContextValidator, + WebAuthnAuthenticationContextValidator authenticationContextValidator) { + this(registrationContextValidator, authenticationContextValidator, new WebAuthnDataConverter()); + } + + public WebAuthn4JWebAuthnManager(WebAuthnDataConverter webAuthnDataConverter) { + this(WebAuthnRegistrationContextValidator.createNonStrictRegistrationContextValidator(), + new WebAuthnAuthenticationContextValidator(), webAuthnDataConverter); + } + + public WebAuthn4JWebAuthnManager() { + this(new WebAuthnDataConverter()); + } + + + public void verifyRegistrationData( + WebAuthnRegistrationData registrationData + ) { + + Assert.notNull(registrationData.getClientDataJSON(), "clientDataJSON must not be null"); + Assert.notNull(registrationData.getAttestationObject(), "attestationObject must not be null"); + if (registrationData.getTransports() != null) { + registrationData.getTransports().forEach(transport -> Assert.hasText(transport, "each transport must have text")); + } + Assert.notNull(registrationData.getServerProperty(), "serverProperty must not be null"); + + WebAuthnRegistrationContext registrationContext = createRegistrationContext(registrationData); + + registrationContextValidator.validate(registrationContext); + } + + @Override + public void verifyAuthenticationData(WebAuthnAuthenticationData authenticationData, WebAuthnAuthenticator webAuthnAuthenticator) { + + //TODO: null check + + WebAuthnAuthenticationContext authenticationContext = createWebAuthnAuthenticationContext(authenticationData); + + AttestationObject attestationObject = cborConverter.readValue(webAuthnAuthenticator.getAttestationObject(), AttestationObject.class); + + Set transports; + if (webAuthnAuthenticator.getTransports() == null) { + transports = Collections.emptySet(); + } else { + transports = webAuthnAuthenticator.getTransports().stream() + .map(transport -> AuthenticatorTransport.create(transport.getValue())) + .collect(Collectors.toSet()); + } + + Authenticator authenticator = new AuthenticatorImpl( + attestationObject.getAuthenticatorData().getAttestedCredentialData(), + attestationObject.getAttestationStatement(), + webAuthnAuthenticator.getCounter(), + transports + ); + + authenticationContextValidator.validate(authenticationContext, authenticator); + + } + + @Override + public String getEffectiveRpId(HttpServletRequest request) { + String effectiveRpId; + if (this.rpId != null) { + effectiveRpId = this.rpId; + } else { + Origin origin = createOrigin(request); + effectiveRpId = origin.getHost(); + } + return effectiveRpId; + } + + public String getRpId() { + return rpId; + } + + public void setRpId(String rpId) { + this.rpId = rpId; + } + + private WebAuthnRegistrationContext createRegistrationContext(WebAuthnRegistrationData webAuthnRegistrationData) { + + byte[] clientDataBytes = webAuthnRegistrationData.getClientDataJSON(); + byte[] attestationObjectBytes = webAuthnRegistrationData.getAttestationObject(); + Set transports = webAuthnRegistrationData.getTransports(); + String clientExtensionsJSON = webAuthnRegistrationData.getClientExtensionsJSON(); + ServerProperty serverProperty = convertToServerProperty(webAuthnRegistrationData.getServerProperty()); + + return new WebAuthnRegistrationContext( + clientDataBytes, + attestationObjectBytes, + transports, + clientExtensionsJSON, + serverProperty, + false, + false, + webAuthnRegistrationData.getExpectedRegistrationExtensionIds()); + } + + private WebAuthnAuthenticationContext createWebAuthnAuthenticationContext(WebAuthnAuthenticationData webAuthnAuthenticationData) { + + ServerProperty serverProperty = convertToServerProperty(webAuthnAuthenticationData.getServerProperty()); + + return new WebAuthnAuthenticationContext( + webAuthnAuthenticationData.getCredentialId(), + webAuthnAuthenticationData.getClientDataJSON(), + webAuthnAuthenticationData.getAuthenticatorData(), + webAuthnAuthenticationData.getSignature(), + webAuthnAuthenticationData.getClientExtensionsJSON(), + serverProperty, + webAuthnAuthenticationData.isUserVerificationRequired(), + webAuthnAuthenticationData.isUserPresenceRequired(), + webAuthnAuthenticationData.getExpectedAuthenticationExtensionIds() + ); + } + + private Origin convertToOrigin(Origin webAuthnOrigin) { + return new Origin(webAuthnOrigin.getScheme(), webAuthnOrigin.getHost(), webAuthnOrigin.getPort()); + } + + private ServerProperty convertToServerProperty(ServerProperty webAuthnServerProperty) { + return new ServerProperty( + convertToOrigin(webAuthnServerProperty.getOrigin()), + webAuthnServerProperty.getRpId(), + new DefaultChallenge(webAuthnServerProperty.getChallenge().getValue()), + webAuthnServerProperty.getTokenBindingId()); + } + + private static Origin createOrigin(ServletRequest request) { + return new Origin(request.getScheme(), request.getServerName(), request.getServerPort()); + } + +} diff --git a/webauthn/src/main/java/org/springframework/security/webauthn/WebAuthnAssertionAuthenticationToken.java b/webauthn/src/main/java/org/springframework/security/webauthn/WebAuthnAssertionAuthenticationToken.java new file mode 100644 index 00000000000..e5689b83e8b --- /dev/null +++ b/webauthn/src/main/java/org/springframework/security/webauthn/WebAuthnAssertionAuthenticationToken.java @@ -0,0 +1,121 @@ +/* + * Copyright 2002-2019 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.webauthn; + +import org.springframework.security.authentication.AbstractAuthenticationToken; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; + +/** + * An {@link Authentication} implementation for representing WebAuthn assertion like + * {@link UsernamePasswordAuthenticationToken} for password authentication + * + * @author Yoshikazu Nojima + */ +public class WebAuthnAssertionAuthenticationToken extends AbstractAuthenticationToken { + + // ~ Instance fields + // ================================================================================================ + private WebAuthnAuthenticationData credentials; + + + // ~ Constructor + // ======================================================================================================== + + /** + * This constructor can be safely used by any code that wishes to create a + * WebAuthnAssertionAuthenticationToken, as the {@link #isAuthenticated()} + * will return false. + * + * @param credentials credential + */ + public WebAuthnAssertionAuthenticationToken(WebAuthnAuthenticationData credentials) { + super(null); + this.credentials = credentials; + setAuthenticated(false); + } + + // ~ Methods + // ======================================================================================================== + + /** + * Always null + * + * @return null + */ + @Override + public String getPrincipal() { + return null; + } + + /** + * @return the stored WebAuthn authentication context + */ + @Override + public WebAuthnAuthenticationData getCredentials() { + return credentials; + } + + /** + * This object can never be authenticated, call with true result in exception. + * + * @param isAuthenticated only false value allowed + * @throws IllegalArgumentException if isAuthenticated is true + */ + @Override + public void setAuthenticated(boolean isAuthenticated) { + if (isAuthenticated) { + throw new IllegalArgumentException( + "Cannot set this authenticator to trusted"); + } + + super.setAuthenticated(false); + } + + /** + * {@inheritDoc} + */ + @Override + public void eraseCredentials() { + super.eraseCredentials(); + credentials = null; + } + + /** + * {@inheritDoc} + */ + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof WebAuthnAssertionAuthenticationToken)) return false; + if (!super.equals(o)) return false; + + WebAuthnAssertionAuthenticationToken that = (WebAuthnAssertionAuthenticationToken) o; + + return credentials != null ? credentials.equals(that.credentials) : that.credentials == null; + } + + /** + * {@inheritDoc} + */ + @Override + public int hashCode() { + int result = super.hashCode(); + result = 31 * result + (credentials != null ? credentials.hashCode() : 0); + return result; + } +} diff --git a/webauthn/src/main/java/org/springframework/security/webauthn/WebAuthnAuthenticationData.java b/webauthn/src/main/java/org/springframework/security/webauthn/WebAuthnAuthenticationData.java new file mode 100644 index 00000000000..dff75d5fe67 --- /dev/null +++ b/webauthn/src/main/java/org/springframework/security/webauthn/WebAuthnAuthenticationData.java @@ -0,0 +1,159 @@ +/* + * Copyright 2002-2019 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.webauthn; + +import com.webauthn4j.server.ServerProperty; +import com.webauthn4j.util.ArrayUtil; + +import java.io.Serializable; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Objects; + + +/** + * Internal data transfer object to represent WebAuthn authentication request + * + * @author Yoshikazu Nojima + */ +public class WebAuthnAuthenticationData implements Serializable { + + //~ Instance fields + // ================================================================================================ + // user inputs + private final byte[] credentialId; + private final byte[] clientDataJSON; + private final byte[] authenticatorData; + private final byte[] signature; + private final String clientExtensionsJSON; + + private final ServerProperty serverProperty; + private final boolean userVerificationRequired; + private final boolean userPresenceRequired; + private final List expectedAuthenticationExtensionIds; + + @SuppressWarnings("squid:S00107") + public WebAuthnAuthenticationData( + byte[] credentialId, + byte[] clientDataJSON, + byte[] authenticatorData, + byte[] signature, + String clientExtensionsJSON, + ServerProperty serverProperty, + boolean userVerificationRequired, + boolean userPresenceRequired, + List expectedAuthenticationExtensionIds) { + + this.credentialId = ArrayUtil.clone(credentialId); + this.clientDataJSON = ArrayUtil.clone(clientDataJSON); + this.authenticatorData = ArrayUtil.clone(authenticatorData); + this.signature = ArrayUtil.clone(signature); + this.clientExtensionsJSON = clientExtensionsJSON; + this.serverProperty = serverProperty; + this.userVerificationRequired = userVerificationRequired; + this.userPresenceRequired = userPresenceRequired; + this.expectedAuthenticationExtensionIds = Collections.unmodifiableList(expectedAuthenticationExtensionIds); + } + + @SuppressWarnings("squid:S00107") + public WebAuthnAuthenticationData( + byte[] credentialId, + byte[] clientDataJSON, + byte[] authenticatorData, + byte[] signature, + String clientExtensionsJSON, + ServerProperty serverProperty, + boolean userVerificationRequired, + List expectedAuthenticationExtensionIds) { + + this( + credentialId, + clientDataJSON, + authenticatorData, + signature, + clientExtensionsJSON, + serverProperty, + userVerificationRequired, + true, + expectedAuthenticationExtensionIds + ); + } + + public byte[] getCredentialId() { + return ArrayUtil.clone(credentialId); + } + + public byte[] getClientDataJSON() { + return ArrayUtil.clone(clientDataJSON); + } + + public byte[] getAuthenticatorData() { + return ArrayUtil.clone(authenticatorData); + } + + public byte[] getSignature() { + return ArrayUtil.clone(signature); + } + + public String getClientExtensionsJSON() { + return clientExtensionsJSON; + } + + public ServerProperty getServerProperty() { + return serverProperty; + } + + public boolean isUserVerificationRequired() { + return userVerificationRequired; + } + + public boolean isUserPresenceRequired() { + return userPresenceRequired; + } + + public List getExpectedAuthenticationExtensionIds() { + return expectedAuthenticationExtensionIds; + } + + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + WebAuthnAuthenticationData that = (WebAuthnAuthenticationData) o; + return userVerificationRequired == that.userVerificationRequired && + userPresenceRequired == that.userPresenceRequired && + Arrays.equals(credentialId, that.credentialId) && + Arrays.equals(clientDataJSON, that.clientDataJSON) && + Arrays.equals(authenticatorData, that.authenticatorData) && + Arrays.equals(signature, that.signature) && + Objects.equals(clientExtensionsJSON, that.clientExtensionsJSON) && + Objects.equals(serverProperty, that.serverProperty) && + Objects.equals(expectedAuthenticationExtensionIds, that.expectedAuthenticationExtensionIds); + } + + @Override + public int hashCode() { + int result = Objects.hash(clientExtensionsJSON, serverProperty, userVerificationRequired, userPresenceRequired, expectedAuthenticationExtensionIds); + result = 31 * result + Arrays.hashCode(credentialId); + result = 31 * result + Arrays.hashCode(clientDataJSON); + result = 31 * result + Arrays.hashCode(authenticatorData); + result = 31 * result + Arrays.hashCode(signature); + return result; + } +} diff --git a/webauthn/src/main/java/org/springframework/security/webauthn/WebAuthnAuthenticationProvider.java b/webauthn/src/main/java/org/springframework/security/webauthn/WebAuthnAuthenticationProvider.java new file mode 100644 index 00000000000..25a58c89555 --- /dev/null +++ b/webauthn/src/main/java/org/springframework/security/webauthn/WebAuthnAuthenticationProvider.java @@ -0,0 +1,293 @@ +/* + * Copyright 2002-2019 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.webauthn; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.springframework.context.support.MessageSourceAccessor; +import org.springframework.security.authentication.*; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.SpringSecurityMessageSource; +import org.springframework.security.core.authority.mapping.GrantedAuthoritiesMapper; +import org.springframework.security.core.authority.mapping.NullAuthoritiesMapper; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.webauthn.authenticator.WebAuthnAuthenticator; +import org.springframework.security.webauthn.authenticator.WebAuthnAuthenticatorService; +import org.springframework.security.webauthn.exception.CredentialIdNotFoundException; +import org.springframework.security.webauthn.userdetails.WebAuthnUserDetails; +import org.springframework.security.webauthn.userdetails.WebAuthnUserDetailsChecker; +import org.springframework.security.webauthn.userdetails.WebAuthnUserDetailsService; +import org.springframework.util.Assert; + +import java.io.Serializable; +import java.util.Arrays; +import java.util.Objects; + +/** + * An {@link AuthenticationProvider} implementation for processing {@link WebAuthnAssertionAuthenticationToken} + * + * @author Yoshikazu Nojima + */ +public class WebAuthnAuthenticationProvider implements AuthenticationProvider { + + //~ Instance fields + // ================================================================================================ + + protected final Log logger = LogFactory.getLog(getClass()); + + protected MessageSourceAccessor messages = SpringSecurityMessageSource.getAccessor(); + private WebAuthnUserDetailsService userDetailsService; + private WebAuthnAuthenticatorService authenticatorService; + private WebAuthnManager webAuthnManager; + private boolean forcePrincipalAsString = false; + private boolean hideCredentialIdNotFoundExceptions = true; + private WebAuthnUserDetailsChecker preAuthenticationChecks = new DefaultPreAuthenticationChecks(); + private WebAuthnUserDetailsChecker postAuthenticationChecks = new DefaultPostAuthenticationChecks(); + private GrantedAuthoritiesMapper authoritiesMapper = new NullAuthoritiesMapper(); + + // ~ Constructor + // ======================================================================================================== + + public WebAuthnAuthenticationProvider( + WebAuthnUserDetailsService userDetailsService, + WebAuthnAuthenticatorService authenticatorService, + WebAuthnManager webAuthnManager) { + + Assert.notNull(userDetailsService, "userDetailsService must not be null"); + Assert.notNull(authenticatorService, "authenticatorService must not be null"); + Assert.notNull(webAuthnManager, "webAuthnManager must not be null"); + + this.userDetailsService = userDetailsService; + this.authenticatorService = authenticatorService; + this.webAuthnManager = webAuthnManager; + } + + // ~ Methods + // ======================================================================================================== + + /** + * {@inheritDoc} + */ + @Override + public Authentication authenticate(Authentication authentication) { + if (!supports(authentication.getClass())) { + throw new IllegalArgumentException("Only WebAuthnAssertionAuthenticationToken is supported, " + authentication.getClass() + " was attempted"); + } + + WebAuthnAssertionAuthenticationToken authenticationToken = (WebAuthnAssertionAuthenticationToken) authentication; + + WebAuthnAuthenticationData credentials = authenticationToken.getCredentials(); + if (credentials == null) { + logger.debug("Authentication failed: no credentials provided"); + + throw new BadCredentialsException(messages.getMessage( + "WebAuthnAuthenticationContextValidator.badCredentials", + "Bad credentials")); + } + + byte[] credentialId = credentials.getCredentialId(); + + WebAuthnUserDetails user = retrieveWebAuthnUserDetails(credentialId); + WebAuthnAuthenticator authenticator = user.getAuthenticators().stream() + .filter(item -> Arrays.equals(item.getCredentialId(), credentialId)) + .findFirst() + .orElse(null); + + preAuthenticationChecks.check(user); + doAuthenticate(authenticationToken, authenticator, user); + postAuthenticationChecks.check(user); + + //noinspection ConstantConditions + authenticatorService.updateCounter(credentialId, authenticator.getCounter()); + + Serializable principalToReturn = user; + + if (forcePrincipalAsString) { + principalToReturn = user.getUsername(); + } + + WebAuthnAuthenticationToken result = new WebAuthnAuthenticationToken( + principalToReturn, authenticationToken.getCredentials(), + authoritiesMapper.mapAuthorities(user.getAuthorities())); + result.setDetails(authenticationToken.getDetails()); + + return result; + } + + /** + * {@inheritDoc} + */ + @Override + public boolean supports(Class authentication) { + return WebAuthnAssertionAuthenticationToken.class.isAssignableFrom(authentication); + } + + void doAuthenticate(WebAuthnAssertionAuthenticationToken authenticationToken, WebAuthnAuthenticator webAuthnAuthenticator, WebAuthnUserDetails user) { + + WebAuthnAuthenticationData webAuthnAuthenticationData = authenticationToken.getCredentials(); + boolean userVerificationRequired = isUserVerificationRequired(user, webAuthnAuthenticationData); + webAuthnAuthenticationData = new WebAuthnAuthenticationData( + webAuthnAuthenticationData.getCredentialId(), + webAuthnAuthenticationData.getClientDataJSON(), + webAuthnAuthenticationData.getAuthenticatorData(), + webAuthnAuthenticationData.getSignature(), + webAuthnAuthenticationData.getClientExtensionsJSON(), + webAuthnAuthenticationData.getServerProperty(), + userVerificationRequired, + webAuthnAuthenticationData.isUserPresenceRequired(), + webAuthnAuthenticationData.getExpectedAuthenticationExtensionIds() + ); + + webAuthnManager.verifyAuthenticationData(webAuthnAuthenticationData, webAuthnAuthenticator); + + } + + public boolean isForcePrincipalAsString() { + return forcePrincipalAsString; + } + + public void setForcePrincipalAsString(boolean forcePrincipalAsString) { + this.forcePrincipalAsString = forcePrincipalAsString; + } + + public boolean isHideCredentialIdNotFoundExceptions() { + return hideCredentialIdNotFoundExceptions; + } + + /** + * By default the WebAuthnAuthenticationProvider throws a + * BadCredentialsException if a credentialId is not found or the credential is + * incorrect. Setting this property to false will cause + * CredentialIdNotFoundExceptions to be thrown instead for the former. Note + * this is considered less secure than throwing BadCredentialsException + * for both exceptions. + * + * @param hideCredentialIdNotFoundExceptions set to false if you wish + * CredentialIdNotFoundExceptions to be thrown instead of the non-specific + * BadCredentialsException (defaults to true) + */ + public void setHideCredentialIdNotFoundExceptions(boolean hideCredentialIdNotFoundExceptions) { + this.hideCredentialIdNotFoundExceptions = hideCredentialIdNotFoundExceptions; + } + + protected WebAuthnUserDetailsService getUserDetailsService() { + return userDetailsService; + } + + public void setUserDetailsService(WebAuthnUserDetailsService userDetailsService) { + this.userDetailsService = userDetailsService; + } + + protected WebAuthnUserDetailsChecker getPreAuthenticationChecks() { + return preAuthenticationChecks; + } + + /** + * Sets the policy will be used to verify the status of the loaded + * WebAuthnUserDetails before validation of the credentials takes place. + * + * @param preAuthenticationChecks strategy to be invoked prior to authentication. + */ + public void setPreAuthenticationChecks(WebAuthnUserDetailsChecker preAuthenticationChecks) { + this.preAuthenticationChecks = preAuthenticationChecks; + } + + protected WebAuthnUserDetailsChecker getPostAuthenticationChecks() { + return postAuthenticationChecks; + } + + public void setPostAuthenticationChecks(WebAuthnUserDetailsChecker postAuthenticationChecks) { + this.postAuthenticationChecks = postAuthenticationChecks; + } + + WebAuthnUserDetails retrieveWebAuthnUserDetails(byte[] credentialId) { + WebAuthnUserDetails user; + try { + user = userDetailsService.loadWebAuthnUserByCredentialId(credentialId); + } catch (CredentialIdNotFoundException notFound) { + if (hideCredentialIdNotFoundExceptions) { + throw new BadCredentialsException(messages.getMessage( + "WebAuthnAuthenticationProvider.badCredentials", + "Bad credentials")); + } else { + throw notFound; + } + } catch (Exception repositoryProblem) { + throw new InternalAuthenticationServiceException(repositoryProblem.getMessage(), repositoryProblem); + } + + if (user == null) { + throw new InternalAuthenticationServiceException( + "UserDetailsService returned null, which is an interface contract violation"); + } + return user; + } + + boolean isUserVerificationRequired(WebAuthnUserDetails user, WebAuthnAuthenticationData credentials) { + + Authentication currentAuthentication = SecurityContextHolder.getContext().getAuthentication(); + + // If current authentication is authenticated and username matches, return false + if (currentAuthentication != null && currentAuthentication.isAuthenticated() && Objects.equals(currentAuthentication.getName(), user.getUsername())) { + return false; + } else { + return credentials.isUserVerificationRequired(); + } + } + + private class DefaultPreAuthenticationChecks implements WebAuthnUserDetailsChecker { + @Override + public void check(WebAuthnUserDetails user) { + if (!user.isAccountNonLocked()) { + logger.debug("User account is locked"); + + throw new LockedException(messages.getMessage( + "WebAuthnAuthenticationProvider.locked", + "User account is locked")); + } + + if (!user.isEnabled()) { + logger.debug("User account is disabled"); + + throw new DisabledException(messages.getMessage( + "WebAuthnAuthenticationProvider.disabled", + "User is disabled")); + } + + if (!user.isAccountNonExpired()) { + logger.debug("User account is expired"); + + throw new AccountExpiredException(messages.getMessage( + "WebAuthnAuthenticationProvider.expired", + "User account has expired")); + } + } + } + + private class DefaultPostAuthenticationChecks implements WebAuthnUserDetailsChecker { + @Override + public void check(WebAuthnUserDetails user) { + if (!user.isCredentialsNonExpired()) { + logger.debug("User account credentials have expired"); + + throw new CredentialsExpiredException(messages.getMessage( + "WebAuthnAuthenticationProvider.credentialsExpired", + "User credentials have expired")); + } + } + } +} diff --git a/webauthn/src/main/java/org/springframework/security/webauthn/WebAuthnAuthenticationToken.java b/webauthn/src/main/java/org/springframework/security/webauthn/WebAuthnAuthenticationToken.java new file mode 100644 index 00000000000..dc79ff982eb --- /dev/null +++ b/webauthn/src/main/java/org/springframework/security/webauthn/WebAuthnAuthenticationToken.java @@ -0,0 +1,99 @@ +/* + * Copyright 2002-2019 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.webauthn; + +import org.springframework.security.authentication.AbstractAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; + +import java.io.Serializable; +import java.util.Collection; + +/** + * An {@link Authentication} implementation that is designed for Web Authentication specification. + * + * @author Yoshikazu Nojima + */ +public class WebAuthnAuthenticationToken extends AbstractAuthenticationToken { + + //~ Instance fields + // ================================================================================================ + private Serializable principal; + private WebAuthnAuthenticationData credentials; + + // ~ Constructor + // ======================================================================================================== + + /** + * Constructor + * + * @param principal principal + * @param credentials credentials + * @param authorities the collection of GrantedAuthority for the principal represented by this authentication object. + */ + public WebAuthnAuthenticationToken(Serializable principal, WebAuthnAuthenticationData credentials, Collection authorities) { + super(authorities); + this.principal = principal; + this.credentials = credentials; + this.setAuthenticated(true); + } + + // ~ Methods + // ======================================================================================================== + + /** + * {@inheritDoc} + */ + @Override + public Serializable getPrincipal() { + return principal; + } + + /** + * {@inheritDoc} + */ + @Override + public WebAuthnAuthenticationData getCredentials() { + return credentials; + } + + /** + * {@inheritDoc} + */ + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof WebAuthnAuthenticationToken)) return false; + if (!super.equals(o)) return false; + + WebAuthnAuthenticationToken that = (WebAuthnAuthenticationToken) o; + + if (principal != null ? !principal.equals(that.principal) : that.principal != null) return false; + return credentials != null ? credentials.equals(that.credentials) : that.credentials == null; + } + + /** + * {@inheritDoc} + */ + @Override + public int hashCode() { + int result = super.hashCode(); + result = 31 * result + (principal != null ? principal.hashCode() : 0); + result = 31 * result + (credentials != null ? credentials.hashCode() : 0); + return result; + } +} diff --git a/webauthn/src/main/java/org/springframework/security/webauthn/WebAuthnDataConverter.java b/webauthn/src/main/java/org/springframework/security/webauthn/WebAuthnDataConverter.java new file mode 100644 index 00000000000..301242620cc --- /dev/null +++ b/webauthn/src/main/java/org/springframework/security/webauthn/WebAuthnDataConverter.java @@ -0,0 +1,79 @@ +/* + * Copyright 2002-2019 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.webauthn; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.dataformat.cbor.CBORFactory; +import com.webauthn4j.converter.AttestationObjectConverter; +import com.webauthn4j.converter.AttestedCredentialDataConverter; +import com.webauthn4j.converter.AuthenticatorDataConverter; +import com.webauthn4j.converter.util.CborConverter; +import com.webauthn4j.converter.util.JsonConverter; +import org.springframework.util.Assert; + +public class WebAuthnDataConverter { + + private ObjectMapper jsonMapper; + private ObjectMapper cborMapper; + + private AttestationObjectConverter attestationObjectConverter; + private AuthenticatorDataConverter authenticatorDataConverter; + private AttestedCredentialDataConverter attestedCredentialDataConverter; + + public WebAuthnDataConverter(ObjectMapper jsonMapper, ObjectMapper cborMapper) { + Assert.notNull(jsonMapper, "jsonMapper must not be null"); + Assert.notNull(cborMapper, "cborMapper must not be null"); + + this.jsonMapper = jsonMapper; + this.cborMapper = cborMapper; + JsonConverter jsonConverter = new JsonConverter(jsonMapper, cborMapper); + CborConverter cborConverter = jsonConverter.getCborConverter(); + + this.attestationObjectConverter = new AttestationObjectConverter(cborConverter); + this.authenticatorDataConverter = new AuthenticatorDataConverter(cborConverter); + this.attestedCredentialDataConverter = new AttestedCredentialDataConverter(cborConverter); + } + + public WebAuthnDataConverter() { + this(new ObjectMapper(), new ObjectMapper(new CBORFactory())); + } + + + public byte[] extractAuthenticatorData(byte[] attestationObject) { + return attestationObjectConverter.extractAuthenticatorData(attestationObject); + } + + public byte[] extractAttestedCredentialData(byte[] authenticatorData) { + return authenticatorDataConverter.extractAttestedCredentialData(authenticatorData); + } + + public byte[] extractCredentialId(byte[] attestedCredentialData) { + return attestedCredentialDataConverter.extractCredentialId(attestedCredentialData); + } + + public long extractSignCount(byte[] authenticatorData) { + return authenticatorDataConverter.extractSignCount(authenticatorData); + } + + public ObjectMapper getJsonMapper() { + return jsonMapper; + } + + public ObjectMapper getCborMapper() { + return cborMapper; + } +} diff --git a/webauthn/src/main/java/org/springframework/security/webauthn/WebAuthnManager.java b/webauthn/src/main/java/org/springframework/security/webauthn/WebAuthnManager.java new file mode 100644 index 00000000000..3e9e454539a --- /dev/null +++ b/webauthn/src/main/java/org/springframework/security/webauthn/WebAuthnManager.java @@ -0,0 +1,28 @@ +/* + * Copyright 2002-2019 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.webauthn; + +import org.springframework.security.webauthn.authenticator.WebAuthnAuthenticator; +import org.springframework.security.webauthn.server.EffectiveRpIdProvider; + +public interface WebAuthnManager extends EffectiveRpIdProvider { + + void verifyRegistrationData(WebAuthnRegistrationData registrationData); + + void verifyAuthenticationData(WebAuthnAuthenticationData authenticationData, WebAuthnAuthenticator authenticator); + +} diff --git a/webauthn/src/main/java/org/springframework/security/webauthn/WebAuthnOptionWebHelper.java b/webauthn/src/main/java/org/springframework/security/webauthn/WebAuthnOptionWebHelper.java new file mode 100644 index 00000000000..c38371403ed --- /dev/null +++ b/webauthn/src/main/java/org/springframework/security/webauthn/WebAuthnOptionWebHelper.java @@ -0,0 +1,60 @@ +/* + * Copyright 2002-2019 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.webauthn; + +import com.webauthn4j.util.Base64UrlUtil; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.security.webauthn.challenge.WebAuthnChallengeRepository; +import org.springframework.security.webauthn.userdetails.WebAuthnUserDetails; +import org.springframework.security.webauthn.userdetails.WebAuthnUserDetailsService; + +import javax.servlet.http.HttpServletRequest; +import java.util.Collections; +import java.util.List; +import java.util.stream.Collectors; + +public class WebAuthnOptionWebHelper { + + private WebAuthnChallengeRepository challengeRepository; + private WebAuthnUserDetailsService userDetailsService; + + public WebAuthnOptionWebHelper(WebAuthnChallengeRepository challengeRepository, WebAuthnUserDetailsService userDetailsService) { + this.challengeRepository = challengeRepository; + this.userDetailsService = userDetailsService; + } + + public String getChallenge(HttpServletRequest request) { + return Base64UrlUtil.encodeToString(challengeRepository.loadOrGenerateChallenge(request).getValue()); + } + + public List getCredentialIds() { + String username = SecurityContextHolder.getContext().getAuthentication().getName(); + if (username == null) { + return Collections.emptyList(); + } else { + try { + WebAuthnUserDetails webAuthnUserDetails = userDetailsService.loadWebAuthnUserByUsername(username); + return webAuthnUserDetails.getAuthenticators().stream() + .map(authenticator -> Base64UrlUtil.encodeToString(authenticator.getCredentialId())) + .collect(Collectors.toList()); + } catch (UsernameNotFoundException e) { + return Collections.emptyList(); + } + } + } +} diff --git a/webauthn/src/main/java/org/springframework/security/webauthn/WebAuthnProcessingFilter.java b/webauthn/src/main/java/org/springframework/security/webauthn/WebAuthnProcessingFilter.java new file mode 100644 index 00000000000..8bdd3e762b2 --- /dev/null +++ b/webauthn/src/main/java/org/springframework/security/webauthn/WebAuthnProcessingFilter.java @@ -0,0 +1,261 @@ +/* + * Copyright 2002-2019 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.webauthn; + +import com.webauthn4j.server.ServerProperty; +import org.springframework.http.HttpMethod; +import org.springframework.security.authentication.AbstractAuthenticationToken; +import org.springframework.security.authentication.AuthenticationServiceException; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.AuthorityUtils; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.security.webauthn.server.WebAuthnServerPropertyProvider; +import org.springframework.util.Assert; +import org.springframework.util.Base64Utils; +import org.springframework.util.StringUtils; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.util.Collections; +import java.util.List; + + +/** + * Processes a WebAuthn authentication form submission. For supporting username/password authentication for first step of + * two step authentication, if credentialId is not found in the HTTP request, this filter try to find username/password + * parameters. + *

+ * Login forms must present WebAuthn parameters (credentialId, clientDataJSON, authenticatorData,signature and + * clientExtensionJSON) or Password authentication parameters (username and password). + * The default parameter names to use are contained in the static fields + * {@link #SPRING_SECURITY_FORM_CREDENTIAL_ID_KEY}, + * {@link #SPRING_SECURITY_FORM_CLIENT_DATA_JSON_KEY}, + * {@link #SPRING_SECURITY_FORM_AUTHENTICATOR_DATA_KEY}, + * {@link #SPRING_SECURITY_FORM_SIGNATURE_KEY}, and + * {@link #SPRING_SECURITY_FORM_CLIENT_EXTENSIONS_JSON_KEY}. + * The parameter names can also be changed by setting the corresponding properties. + *

+ * This filter by default responds to the URL {@code /login}. + * + * @author Yoshikazu Nojima + * @see WebAuthnAuthenticationProvider + */ +public class WebAuthnProcessingFilter extends UsernamePasswordAuthenticationFilter { + + // ~ Static fields/initializers + // ===================================================================================== + public static final String SPRING_SECURITY_FORM_CREDENTIAL_ID_KEY = "credentialId"; + public static final String SPRING_SECURITY_FORM_CLIENT_DATA_JSON_KEY = "clientDataJSON"; + public static final String SPRING_SECURITY_FORM_AUTHENTICATOR_DATA_KEY = "authenticatorData"; + public static final String SPRING_SECURITY_FORM_SIGNATURE_KEY = "signature"; + public static final String SPRING_SECURITY_FORM_CLIENT_EXTENSIONS_JSON_KEY = "clientExtensionsJSON"; + + //~ Instance fields + // ================================================================================================ + private List authorities; + + private String credentialIdParameter = SPRING_SECURITY_FORM_CREDENTIAL_ID_KEY; + private String clientDataJSONParameter = SPRING_SECURITY_FORM_CLIENT_DATA_JSON_KEY; + private String authenticatorDataParameter = SPRING_SECURITY_FORM_AUTHENTICATOR_DATA_KEY; + private String signatureParameter = SPRING_SECURITY_FORM_SIGNATURE_KEY; + private String clientExtensionsJSONParameter = SPRING_SECURITY_FORM_CLIENT_EXTENSIONS_JSON_KEY; + + private WebAuthnServerPropertyProvider serverPropertyProvider; + + private List expectedAuthenticationExtensionIds = Collections.emptyList(); + + private boolean postOnly = true; + + // ~ Constructors + // =================================================================================================== + + /** + * Constructor + */ + public WebAuthnProcessingFilter() { + super(); + this.authorities = AuthorityUtils.createAuthorityList("ROLE_ANONYMOUS"); + } + + /** + * Constructor + * + * @param authorities authorities for FirstOfMultiFactorAuthenticationToken + * @param serverPropertyProvider provider for ServerProperty + */ + public WebAuthnProcessingFilter(List authorities, WebAuthnServerPropertyProvider serverPropertyProvider) { + super(); + Assert.notNull(authorities, "authorities must not be null"); + Assert.notNull(serverPropertyProvider, "serverPropertyProvider must not be null"); + this.authorities = authorities; + this.serverPropertyProvider = serverPropertyProvider; + } + + // ~ Methods + // ======================================================================================================== + + @Override + public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) { + if (postOnly && !HttpMethod.POST.matches(request.getMethod())) { + throw new AuthenticationServiceException( + "Authentication method not supported: " + request.getMethod()); + } + + String username = obtainUsername(request); + String password = obtainPassword(request); + + String credentialId = obtainCredentialId(request); + String clientDataJSON = obtainClientDataJSON(request); + String authenticatorData = obtainAuthenticatorData(request); + String signature = obtainSignatureData(request); + String clientExtensionsJSON = obtainClientExtensionsJSON(request); + + AbstractAuthenticationToken authRequest; + if (StringUtils.isEmpty(credentialId)) { + authRequest = new UsernamePasswordAuthenticationToken(username, password, authorities); + } else { + byte[] rawId = Base64Utils.decodeFromUrlSafeString(credentialId); + byte[] rawClientData = clientDataJSON == null ? null : Base64Utils.decodeFromUrlSafeString(clientDataJSON); + byte[] rawAuthenticatorData = authenticatorData == null ? null : Base64Utils.decodeFromUrlSafeString(authenticatorData); + byte[] signatureBytes = signature == null ? null : Base64Utils.decodeFromUrlSafeString(signature); + + ServerProperty webAuthnServerProperty = serverPropertyProvider.provide(request); + + WebAuthnAuthenticationData webAuthnAuthenticationData = new WebAuthnAuthenticationData( + rawId, + rawClientData, + rawAuthenticatorData, + signatureBytes, + clientExtensionsJSON, + webAuthnServerProperty, + true, + expectedAuthenticationExtensionIds + ); + authRequest = new WebAuthnAssertionAuthenticationToken(webAuthnAuthenticationData); + } + + // Allow subclasses to set the "details" property + setDetails(request, authRequest); + + return this.getAuthenticationManager().authenticate(authRequest); + } + + /** + * Defines whether only HTTP POST requests will be allowed by this filter. If set to + * true, and an authentication request is received which is not a POST request, an + * exception will be raised immediately and authentication will not be attempted. The + * unsuccessfulAuthentication() method will be called as if handling a failed + * authentication. + *

+ * Defaults to true but may be overridden by subclasses. + * + * @param postOnly Flag to restrict HTTP method to POST. + */ + @Override + public void setPostOnly(boolean postOnly) { + this.postOnly = postOnly; + } + + public String getCredentialIdParameter() { + return credentialIdParameter; + } + + public void setCredentialIdParameter(String credentialIdParameter) { + this.credentialIdParameter = credentialIdParameter; + } + + public String getClientDataJSONParameter() { + return clientDataJSONParameter; + } + + public void setClientDataJSONParameter(String clientDataJSONParameter) { + this.clientDataJSONParameter = clientDataJSONParameter; + } + + public String getAuthenticatorDataParameter() { + return authenticatorDataParameter; + } + + public void setAuthenticatorDataParameter(String authenticatorDataParameter) { + this.authenticatorDataParameter = authenticatorDataParameter; + } + + public String getSignatureParameter() { + return signatureParameter; + } + + public void setSignatureParameter(String signatureParameter) { + this.signatureParameter = signatureParameter; + } + + public String getClientExtensionsJSONParameter() { + return clientExtensionsJSONParameter; + } + + public void setClientExtensionsJSONParameter(String clientExtensionsJSONParameter) { + this.clientExtensionsJSONParameter = clientExtensionsJSONParameter; + } + + public List getExpectedAuthenticationExtensionIds() { + return expectedAuthenticationExtensionIds; + } + + /** + * Sets expected authentication extensionId list + * + * @param expectedAuthenticationExtensionIds list of expected authentication extensionId + */ + public void setExpectedAuthenticationExtensionIds(List expectedAuthenticationExtensionIds) { + this.expectedAuthenticationExtensionIds = expectedAuthenticationExtensionIds; + } + + public WebAuthnServerPropertyProvider getServerPropertyProvider() { + return serverPropertyProvider; + } + + public void setServerPropertyProvider(WebAuthnServerPropertyProvider serverPropertyProvider) { + this.serverPropertyProvider = serverPropertyProvider; + } + + + private String obtainClientDataJSON(HttpServletRequest request) { + return request.getParameter(clientDataJSONParameter); + } + + private String obtainCredentialId(HttpServletRequest request) { + return request.getParameter(credentialIdParameter); + } + + private String obtainAuthenticatorData(HttpServletRequest request) { + return request.getParameter(authenticatorDataParameter); + } + + private String obtainSignatureData(HttpServletRequest request) { + return request.getParameter(signatureParameter); + } + + private String obtainClientExtensionsJSON(HttpServletRequest request) { + return request.getParameter(clientExtensionsJSONParameter); + } + + private void setDetails(HttpServletRequest request, + AbstractAuthenticationToken authRequest) { + authRequest.setDetails(authenticationDetailsSource.buildDetails(request)); + } +} diff --git a/webauthn/src/main/java/org/springframework/security/webauthn/WebAuthnRegistrationData.java b/webauthn/src/main/java/org/springframework/security/webauthn/WebAuthnRegistrationData.java new file mode 100644 index 00000000000..ce3bb9d03bb --- /dev/null +++ b/webauthn/src/main/java/org/springframework/security/webauthn/WebAuthnRegistrationData.java @@ -0,0 +1,70 @@ +/* + * Copyright 2002-2019 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.webauthn; + +import com.webauthn4j.server.ServerProperty; +import com.webauthn4j.util.ArrayUtil; + +import java.util.Collections; +import java.util.List; +import java.util.Set; + +public class WebAuthnRegistrationData { + + private final byte[] clientDataJSON; + private final byte[] attestationObject; + private final Set transports; + private final String clientExtensionsJSON; + + private final ServerProperty serverProperty; + private final List expectedRegistrationExtensionIds; + + public WebAuthnRegistrationData(byte[] clientDataJSON, byte[] attestationObject, Set transports, String clientExtensionsJSON, + ServerProperty serverProperty, + List expectedRegistrationExtensionIds) { + this.clientDataJSON = ArrayUtil.clone(clientDataJSON); + this.attestationObject = ArrayUtil.clone(attestationObject); + this.transports = transports == null ? null : Collections.unmodifiableSet(transports); + this.clientExtensionsJSON = clientExtensionsJSON; + this.serverProperty = serverProperty; + this.expectedRegistrationExtensionIds = expectedRegistrationExtensionIds == null ? null : Collections.unmodifiableList(expectedRegistrationExtensionIds); + } + + public byte[] getClientDataJSON() { + return ArrayUtil.clone(clientDataJSON); + } + + public byte[] getAttestationObject() { + return ArrayUtil.clone(attestationObject); + } + + public Set getTransports() { + return transports; + } + + public String getClientExtensionsJSON() { + return clientExtensionsJSON; + } + + public ServerProperty getServerProperty() { + return serverProperty; + } + + public List getExpectedRegistrationExtensionIds() { + return expectedRegistrationExtensionIds; + } +} diff --git a/webauthn/src/main/java/org/springframework/security/webauthn/WebAuthnRegistrationRequest.java b/webauthn/src/main/java/org/springframework/security/webauthn/WebAuthnRegistrationRequest.java new file mode 100644 index 00000000000..742530b6c8d --- /dev/null +++ b/webauthn/src/main/java/org/springframework/security/webauthn/WebAuthnRegistrationRequest.java @@ -0,0 +1,81 @@ +/* + * Copyright 2002-2019 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.webauthn; + +import javax.servlet.http.HttpServletRequest; +import java.util.Collections; +import java.util.Objects; +import java.util.Set; + +public class WebAuthnRegistrationRequest { + + private HttpServletRequest httpServletRequest; + private String clientDataBase64Url; + private String attestationObjectBase64Url; + private Set transports; + private String clientExtensionsJSON; + + public WebAuthnRegistrationRequest( + HttpServletRequest httpServletRequest, + String clientDataBase64Url, + String attestationObjectBase64Url, + Set transports, + String clientExtensionsJSON) { + this.httpServletRequest = httpServletRequest; + this.clientDataBase64Url = clientDataBase64Url; + this.attestationObjectBase64Url = attestationObjectBase64Url; + this.transports = transports == null ? null : Collections.unmodifiableSet(transports); + this.clientExtensionsJSON = clientExtensionsJSON; + } + + public HttpServletRequest getHttpServletRequest() { + return httpServletRequest; + } + + public String getClientDataBase64Url() { + return clientDataBase64Url; + } + + public String getAttestationObjectBase64Url() { + return attestationObjectBase64Url; + } + + public Set getTransports() { + return transports; + } + + public String getClientExtensionsJSON() { + return clientExtensionsJSON; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + WebAuthnRegistrationRequest that = (WebAuthnRegistrationRequest) o; + return Objects.equals(httpServletRequest, that.httpServletRequest) && + Objects.equals(clientDataBase64Url, that.clientDataBase64Url) && + Objects.equals(attestationObjectBase64Url, that.attestationObjectBase64Url) && + Objects.equals(transports, that.transports) && + Objects.equals(clientExtensionsJSON, that.clientExtensionsJSON); + } + + @Override + public int hashCode() { + return Objects.hash(httpServletRequest, clientDataBase64Url, attestationObjectBase64Url, transports, clientExtensionsJSON); + } +} diff --git a/webauthn/src/main/java/org/springframework/security/webauthn/WebAuthnRegistrationRequestValidator.java b/webauthn/src/main/java/org/springframework/security/webauthn/WebAuthnRegistrationRequestValidator.java new file mode 100644 index 00000000000..82ac1aac663 --- /dev/null +++ b/webauthn/src/main/java/org/springframework/security/webauthn/WebAuthnRegistrationRequestValidator.java @@ -0,0 +1,67 @@ +/* + * Copyright 2002-2019 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.webauthn; + +import com.webauthn4j.server.ServerProperty; +import com.webauthn4j.util.Base64UrlUtil; +import org.springframework.security.webauthn.server.WebAuthnServerPropertyProvider; +import org.springframework.util.Assert; + +import java.util.List; + +public class WebAuthnRegistrationRequestValidator { + + private WebAuthnManager webAuthnManager; + private WebAuthnServerPropertyProvider webAuthnServerPropertyProvider; + + private List expectedRegistrationExtensionIds; + + public WebAuthnRegistrationRequestValidator( + WebAuthnManager webAuthnManager, + WebAuthnServerPropertyProvider webAuthnServerPropertyProvider) { + + this.webAuthnManager = webAuthnManager; + this.webAuthnServerPropertyProvider = webAuthnServerPropertyProvider; + } + + public void validate(WebAuthnRegistrationRequest registrationRequest) { + + Assert.notNull(registrationRequest, "target must not be null"); + Assert.notNull(registrationRequest.getHttpServletRequest(), "httpServletRequest must not be null"); + + ServerProperty webAuthnServerProperty = webAuthnServerPropertyProvider.provide(registrationRequest.getHttpServletRequest()); + + WebAuthnRegistrationData webAuthnRegistrationData = new WebAuthnRegistrationData( + Base64UrlUtil.decode(registrationRequest.getClientDataBase64Url()), + Base64UrlUtil.decode(registrationRequest.getAttestationObjectBase64Url()), + registrationRequest.getTransports(), + registrationRequest.getClientExtensionsJSON(), + webAuthnServerProperty, + expectedRegistrationExtensionIds); + + webAuthnManager.verifyRegistrationData(webAuthnRegistrationData); + } + + public List getExpectedRegistrationExtensionIds() { + return expectedRegistrationExtensionIds; + } + + public void setExpectedRegistrationExtensionIds(List expectedRegistrationExtensionIds) { + this.expectedRegistrationExtensionIds = expectedRegistrationExtensionIds; + } + +} diff --git a/webauthn/src/main/java/org/springframework/security/webauthn/authenticator/WebAuthnAuthenticator.java b/webauthn/src/main/java/org/springframework/security/webauthn/authenticator/WebAuthnAuthenticator.java new file mode 100644 index 00000000000..86958e18a42 --- /dev/null +++ b/webauthn/src/main/java/org/springframework/security/webauthn/authenticator/WebAuthnAuthenticator.java @@ -0,0 +1,44 @@ +/* + * Copyright 2002-2019 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.webauthn.authenticator; + +import com.webauthn4j.data.AuthenticatorTransport; +import org.springframework.security.webauthn.userdetails.WebAuthnUserDetailsService; + +import java.util.Set; + +/** + * Models core authenticator information retrieved by a {@link WebAuthnUserDetailsService} + * + * @author Yoshikazu Nojima + * @see WebAuthnUserDetailsService + */ +public interface WebAuthnAuthenticator { + + byte[] getCredentialId(); + + byte[] getAttestationObject(); + + long getCounter(); + + void setCounter(long counter); + + Set getTransports(); + + String getClientExtensions(); + +} diff --git a/webauthn/src/main/java/org/springframework/security/webauthn/authenticator/WebAuthnAuthenticatorImpl.java b/webauthn/src/main/java/org/springframework/security/webauthn/authenticator/WebAuthnAuthenticatorImpl.java new file mode 100644 index 00000000000..092944e7a7c --- /dev/null +++ b/webauthn/src/main/java/org/springframework/security/webauthn/authenticator/WebAuthnAuthenticatorImpl.java @@ -0,0 +1,121 @@ +/* + * Copyright 2002-2019 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.webauthn.authenticator; + + +import com.webauthn4j.data.AuthenticatorTransport; +import com.webauthn4j.util.ArrayUtil; + +import java.util.Arrays; +import java.util.Objects; +import java.util.Set; + +public class WebAuthnAuthenticatorImpl implements WebAuthnAuthenticator { + + // ~ Instance fields + // ================================================================================================ + private byte[] credentialId; + private String name; + private byte[] attestationObject; + private long counter; + private Set transports; + private String clientExtensions; + + // ~ Constructor + // ======================================================================================================== + + /** + * Constructor + * + * @param credentialId credential id + * @param name authenticator's friendly name + * @param attestationObject attestation object + * @param counter counter + * @param transports transports + */ + public WebAuthnAuthenticatorImpl( + byte[] credentialId, + String name, + byte[] attestationObject, + long counter, + Set transports, + String clientExtensions) { + this.credentialId = credentialId; + this.name = name; + this.attestationObject = ArrayUtil.clone(attestationObject); + this.counter = counter; + this.transports = transports; + this.clientExtensions = clientExtensions; + } + + // ~ Methods + // ======================================================================================================== + + @Override + public byte[] getCredentialId() { + return credentialId; + } + + public String getName() { + return name; + } + + @Override + public byte[] getAttestationObject() { + return attestationObject; + } + + public long getCounter() { + return counter; + } + + @Override + public void setCounter(long counter) { + this.counter = counter; + } + + @Override + public Set getTransports() { + return transports; + } + + @Override + public String getClientExtensions() { + return clientExtensions; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + WebAuthnAuthenticatorImpl that = (WebAuthnAuthenticatorImpl) o; + return counter == that.counter && + Arrays.equals(credentialId, that.credentialId) && + Objects.equals(name, that.name) && + Arrays.equals(attestationObject, that.attestationObject) && + Objects.equals(transports, that.transports) && + Objects.equals(clientExtensions, that.clientExtensions); + } + + @Override + public int hashCode() { + int result = Objects.hash(name, counter, transports, clientExtensions); + result = 31 * result + Arrays.hashCode(credentialId); + result = 31 * result + Arrays.hashCode(attestationObject); + return result; + } +} diff --git a/webauthn/src/main/java/org/springframework/security/webauthn/authenticator/WebAuthnAuthenticatorService.java b/webauthn/src/main/java/org/springframework/security/webauthn/authenticator/WebAuthnAuthenticatorService.java new file mode 100644 index 00000000000..1168571f863 --- /dev/null +++ b/webauthn/src/main/java/org/springframework/security/webauthn/authenticator/WebAuthnAuthenticatorService.java @@ -0,0 +1,36 @@ +/* + * Copyright 2002-2019 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.webauthn.authenticator; + +import org.springframework.security.webauthn.exception.CredentialIdNotFoundException; + +/** + * Core interface for manipulating persisted authenticator + */ +public interface WebAuthnAuthenticatorService { + + /** + * Updates Authenticator counter + * + * @param credentialId credentialId + * @param counter counter + * @throws CredentialIdNotFoundException if the authenticator could not be found + */ + @SuppressWarnings("squid:RedundantThrowsDeclarationCheck") + void updateCounter(byte[] credentialId, long counter) throws CredentialIdNotFoundException; + +} diff --git a/webauthn/src/main/java/org/springframework/security/webauthn/challenge/HttpSessionWebAuthnChallengeRepository.java b/webauthn/src/main/java/org/springframework/security/webauthn/challenge/HttpSessionWebAuthnChallengeRepository.java new file mode 100644 index 00000000000..aeebce4859d --- /dev/null +++ b/webauthn/src/main/java/org/springframework/security/webauthn/challenge/HttpSessionWebAuthnChallengeRepository.java @@ -0,0 +1,87 @@ +/* + * Copyright 2002-2019 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.webauthn.challenge; + +import com.webauthn4j.data.client.challenge.Challenge; +import com.webauthn4j.data.client.challenge.DefaultChallenge; +import org.springframework.security.web.csrf.HttpSessionCsrfTokenRepository; +import org.springframework.util.Assert; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpSession; + +/** + * A {@link WebAuthnChallengeRepository} implementation that stores data to HTTP session + *

+ * Class design is based on {@link HttpSessionCsrfTokenRepository} + * + * @author Yoshikazu Nojima + */ +public class HttpSessionWebAuthnChallengeRepository implements WebAuthnChallengeRepository { + + // ~ Static fields/initializers + // ===================================================================================== + + private static final String DEFAULT_CHALLENGE_ATTR_NAME = HttpSessionWebAuthnChallengeRepository.class + .getName().concat(".CHALLENGE"); + + //~ Instance fields + // ================================================================================================ + private String sessionAttributeName = DEFAULT_CHALLENGE_ATTR_NAME; + + // ~ Methods + // ======================================================================================================== + + @Override + public Challenge generateChallenge() { + return new DefaultChallenge(); + } + + @Override + public void saveChallenge(Challenge challenge, HttpServletRequest request) { + if (challenge == null) { + HttpSession session = request.getSession(false); + if (session != null) { + session.removeAttribute(this.sessionAttributeName); + } + } else { + HttpSession session = request.getSession(); + session.setAttribute(this.sessionAttributeName, challenge); + } + } + + @Override + public Challenge loadChallenge(HttpServletRequest request) { + HttpSession session = request.getSession(false); + if (session == null) { + return null; + } + return (Challenge) session.getAttribute(this.sessionAttributeName); + } + + /** + * Sets the {@link HttpSession} attribute name that the {@link Challenge} is stored in + * + * @param sessionAttributeName the new attribute name to use + */ + public void setSessionAttributeName(String sessionAttributeName) { + Assert.hasLength(sessionAttributeName, + "sessionAttributename cannot be null or empty"); + this.sessionAttributeName = sessionAttributeName; + } + +} diff --git a/webauthn/src/main/java/org/springframework/security/webauthn/challenge/WebAuthnChallengeRepository.java b/webauthn/src/main/java/org/springframework/security/webauthn/challenge/WebAuthnChallengeRepository.java new file mode 100644 index 00000000000..76b59ddc1e6 --- /dev/null +++ b/webauthn/src/main/java/org/springframework/security/webauthn/challenge/WebAuthnChallengeRepository.java @@ -0,0 +1,76 @@ +/* + * Copyright 2002-2019 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.webauthn.challenge; + + +import com.webauthn4j.data.client.challenge.Challenge; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import javax.servlet.http.HttpSession; + +/** + * An API to allow changing the method in which the expected {@link Challenge} is + * associated to the {@link HttpServletRequest}. For example, it may be stored in + * {@link HttpSession}. + * + * @see HttpSessionWebAuthnChallengeRepository + */ +public interface WebAuthnChallengeRepository { + + /** + * Generates a {@link Challenge} + * + * @return the {@link Challenge} that was generated. Cannot be null. + */ + Challenge generateChallenge(); + + /** + * Saves the {@link Challenge} using the {@link HttpServletRequest} and + * {@link HttpServletResponse}. If the {@link Challenge} is null, it is the same as + * deleting it. + * + * @param challenge the {@link Challenge} to save or null to delete + * @param request the {@link HttpServletRequest} to use + */ + void saveChallenge(Challenge challenge, HttpServletRequest request); + + /** + * Loads the expected {@link Challenge} from the {@link HttpServletRequest} + * + * @param request the {@link HttpServletRequest} to use + * @return the {@link Challenge} or null if none exists + */ + Challenge loadChallenge(HttpServletRequest request); + + /** + * Loads or generates {@link Challenge} from the {@link HttpServletRequest} + * + * @param request the {@link HttpServletRequest} to use + * @return the {@link Challenge} or null if none exists + */ + default Challenge loadOrGenerateChallenge(HttpServletRequest request) { + Challenge challenge = this.loadChallenge(request); + if (challenge == null) { + challenge = this.generateChallenge(); + this.saveChallenge(challenge, request); + } + return challenge; + } + +} + diff --git a/webauthn/src/main/java/org/springframework/security/webauthn/config/configurers/WebAuthnAuthenticationProviderConfigurer.java b/webauthn/src/main/java/org/springframework/security/webauthn/config/configurers/WebAuthnAuthenticationProviderConfigurer.java new file mode 100644 index 00000000000..28c0aa41221 --- /dev/null +++ b/webauthn/src/main/java/org/springframework/security/webauthn/config/configurers/WebAuthnAuthenticationProviderConfigurer.java @@ -0,0 +1,73 @@ +/* + * Copyright 2002-2019 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.webauthn.config.configurers; + +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.config.annotation.SecurityConfigurerAdapter; +import org.springframework.security.config.annotation.authentication.ProviderManagerBuilder; +import org.springframework.security.webauthn.WebAuthnAuthenticationProvider; +import org.springframework.security.webauthn.WebAuthnManager; +import org.springframework.security.webauthn.authenticator.WebAuthnAuthenticatorService; +import org.springframework.security.webauthn.userdetails.WebAuthnUserDetailsService; +import org.springframework.util.Assert; + +/** + * Allows configuring a {@link WebAuthnAuthenticationProvider} + */ +public class WebAuthnAuthenticationProviderConfigurer< + B extends ProviderManagerBuilder, + U extends WebAuthnUserDetailsService, + A extends WebAuthnAuthenticatorService, + V extends WebAuthnManager> + extends SecurityConfigurerAdapter { + + //~ Instance fields + // ================================================================================================ + private U userDetailsService; + private A authenticatorService; + private V webAuthnAuthenticationManager; + + /** + * Constructor + * + * @param userDetailsService {@link WebAuthnUserDetailsService} + * @param authenticatorService {@link WebAuthnAuthenticatorService} + * @param webAuthnAuthenticationManager {@link WebAuthnManager} + */ + public WebAuthnAuthenticationProviderConfigurer(U userDetailsService, A authenticatorService, V webAuthnAuthenticationManager) { + + Assert.notNull(userDetailsService, "userDetailsService must not be null"); + Assert.notNull(authenticatorService, "authenticatorService must not be null"); + Assert.notNull(webAuthnAuthenticationManager, "webAuthnAuthenticationManager must not be null"); + + this.userDetailsService = userDetailsService; + this.authenticatorService = authenticatorService; + this.webAuthnAuthenticationManager = webAuthnAuthenticationManager; + } + + // ~ Methods + // ======================================================================================================== + + @Override + public void configure(B builder) { + WebAuthnAuthenticationProvider authenticationProvider = + new WebAuthnAuthenticationProvider(userDetailsService, authenticatorService, webAuthnAuthenticationManager); + authenticationProvider = postProcess(authenticationProvider); + builder.authenticationProvider(authenticationProvider); + } + +} diff --git a/webauthn/src/main/java/org/springframework/security/webauthn/config/configurers/WebAuthnConfigurerUtil.java b/webauthn/src/main/java/org/springframework/security/webauthn/config/configurers/WebAuthnConfigurerUtil.java new file mode 100644 index 00000000000..a0b7c951cfd --- /dev/null +++ b/webauthn/src/main/java/org/springframework/security/webauthn/config/configurers/WebAuthnConfigurerUtil.java @@ -0,0 +1,130 @@ +/* + * Copyright 2002-2019 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.webauthn.config.configurers; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.dataformat.cbor.CBORFactory; +import com.webauthn4j.converter.util.CborConverter; +import com.webauthn4j.converter.util.JsonConverter; +import com.webauthn4j.validator.WebAuthnAuthenticationContextValidator; +import com.webauthn4j.validator.WebAuthnRegistrationContextValidator; +import org.springframework.context.ApplicationContext; +import org.springframework.security.config.annotation.web.HttpSecurityBuilder; +import org.springframework.security.webauthn.WebAuthn4JWebAuthnManager; +import org.springframework.security.webauthn.WebAuthnDataConverter; +import org.springframework.security.webauthn.WebAuthnManager; +import org.springframework.security.webauthn.challenge.HttpSessionWebAuthnChallengeRepository; +import org.springframework.security.webauthn.challenge.WebAuthnChallengeRepository; +import org.springframework.security.webauthn.server.WebAuthnServerPropertyProvider; +import org.springframework.security.webauthn.server.WebAuthnServerPropertyProviderImpl; +import org.springframework.security.webauthn.userdetails.WebAuthnUserDetailsService; + +/** + * Internal utility for WebAuthn Configurers + */ +public class WebAuthnConfigurerUtil { + + private WebAuthnConfigurerUtil() { + } + + public static > WebAuthnManager getOrCreateWebAuthnAuthenticationManager(H http) { + ApplicationContext applicationContext = http.getSharedObject(ApplicationContext.class); + WebAuthnManager webAuthnManager; + String[] beanNames = applicationContext.getBeanNamesForType(WebAuthnManager.class); + if (beanNames.length == 0) { + WebAuthnDataConverter webAuthnDataConverter = getOrCreateWebAuthnDataConverter(http); + webAuthnManager = new WebAuthn4JWebAuthnManager( + getOrCreateWebAuthnRegistrationContextValidator(http), + getOrCreateWebAuthnAuthenticationContextValidator(http), + webAuthnDataConverter + ); + } else { + webAuthnManager = applicationContext.getBean(WebAuthnManager.class); + } + return webAuthnManager; + } + + public static > WebAuthnServerPropertyProvider getOrCreateServerPropertyProvider(H http) { + ApplicationContext applicationContext = http.getSharedObject(ApplicationContext.class); + WebAuthnServerPropertyProvider webAuthnServerPropertyProvider; + String[] beanNames = applicationContext.getBeanNamesForType(WebAuthnServerPropertyProvider.class); + if (beanNames.length == 0) { + webAuthnServerPropertyProvider = new WebAuthnServerPropertyProviderImpl(getOrCreateWebAuthnAuthenticationManager(http), getOrCreateChallengeRepository(http)); + } else { + webAuthnServerPropertyProvider = applicationContext.getBean(WebAuthnServerPropertyProvider.class); + } + return webAuthnServerPropertyProvider; + } + + public static > WebAuthnUserDetailsService getWebAuthnUserDetailsService(H http) { + ApplicationContext applicationContext = http.getSharedObject(ApplicationContext.class); + return applicationContext.getBean(WebAuthnUserDetailsService.class); + } + + static > WebAuthnDataConverter getOrCreateWebAuthnDataConverter(H http) { + ApplicationContext applicationContext = http.getSharedObject(ApplicationContext.class); + WebAuthnDataConverter webAuthnDataConverter; + String[] beanNames = applicationContext.getBeanNamesForType(JsonConverter.class); + if (beanNames.length == 0) { + ObjectMapper jsonMapper = new ObjectMapper(); + ObjectMapper cborMapper = new ObjectMapper(new CBORFactory()); + webAuthnDataConverter = new WebAuthnDataConverter(jsonMapper, cborMapper); + } else { + webAuthnDataConverter = applicationContext.getBean(WebAuthnDataConverter.class); + } + return webAuthnDataConverter; + } + + private static > WebAuthnRegistrationContextValidator getOrCreateWebAuthnRegistrationContextValidator(H http) { + ApplicationContext applicationContext = http.getSharedObject(ApplicationContext.class); + WebAuthnRegistrationContextValidator webAuthnRegistrationContextValidator; + String[] beanNames = applicationContext.getBeanNamesForType(WebAuthnRegistrationContextValidator.class); + if (beanNames.length == 0) { + webAuthnRegistrationContextValidator = WebAuthnRegistrationContextValidator.createNonStrictRegistrationContextValidator(); + } else { + webAuthnRegistrationContextValidator = applicationContext.getBean(WebAuthnRegistrationContextValidator.class); + } + return webAuthnRegistrationContextValidator; + } + + private static > WebAuthnAuthenticationContextValidator getOrCreateWebAuthnAuthenticationContextValidator(H http) { + ApplicationContext applicationContext = http.getSharedObject(ApplicationContext.class); + WebAuthnAuthenticationContextValidator webAuthnAuthenticationContextValidator; + String[] beanNames = applicationContext.getBeanNamesForType(WebAuthnAuthenticationContextValidator.class); + if (beanNames.length == 0) { + WebAuthnDataConverter webAuthnDataConverter = getOrCreateWebAuthnDataConverter(http); + JsonConverter jsonConverter = new JsonConverter(webAuthnDataConverter.getJsonMapper(), webAuthnDataConverter.getCborMapper()); + CborConverter cborConverter = new CborConverter(webAuthnDataConverter.getJsonMapper(), webAuthnDataConverter.getCborMapper()); + webAuthnAuthenticationContextValidator = new WebAuthnAuthenticationContextValidator(jsonConverter, cborConverter); + } else { + webAuthnAuthenticationContextValidator = applicationContext.getBean(WebAuthnAuthenticationContextValidator.class); + } + return webAuthnAuthenticationContextValidator; + } + + private static > WebAuthnChallengeRepository getOrCreateChallengeRepository(H http) { + ApplicationContext applicationContext = http.getSharedObject(ApplicationContext.class); + WebAuthnChallengeRepository webAuthnChallengeRepository; + String[] beanNames = applicationContext.getBeanNamesForType(WebAuthnChallengeRepository.class); + if (beanNames.length == 0) { + webAuthnChallengeRepository = new HttpSessionWebAuthnChallengeRepository(); + } else { + webAuthnChallengeRepository = applicationContext.getBean(WebAuthnChallengeRepository.class); + } + return webAuthnChallengeRepository; + } +} diff --git a/webauthn/src/main/java/org/springframework/security/webauthn/config/configurers/WebAuthnLoginConfigurer.java b/webauthn/src/main/java/org/springframework/security/webauthn/config/configurers/WebAuthnLoginConfigurer.java new file mode 100644 index 00000000000..999b934bffb --- /dev/null +++ b/webauthn/src/main/java/org/springframework/security/webauthn/config/configurers/WebAuthnLoginConfigurer.java @@ -0,0 +1,341 @@ +/* + * Copyright 2002-2019 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.webauthn.config.configurers; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.MFATokenEvaluator; +import org.springframework.security.config.annotation.web.HttpSecurityBuilder; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter; +import org.springframework.security.config.annotation.web.configurers.AbstractAuthenticationFilterConfigurer; +import org.springframework.security.config.annotation.web.configurers.FormLoginConfigurer; +import org.springframework.security.web.authentication.ForwardAuthenticationFailureHandler; +import org.springframework.security.web.authentication.ForwardAuthenticationSuccessHandler; +import org.springframework.security.web.util.matcher.AntPathRequestMatcher; +import org.springframework.security.web.util.matcher.RequestMatcher; +import org.springframework.security.webauthn.WebAuthnDataConverter; +import org.springframework.security.webauthn.WebAuthnProcessingFilter; +import org.springframework.security.webauthn.challenge.WebAuthnChallengeRepository; +import org.springframework.security.webauthn.server.WebAuthnServerPropertyProvider; +import org.springframework.util.Assert; + + +/** + * Adds WebAuthn authentication. All attributes have reasonable defaults making all + * parameters are optional. If no {@link #loginPage(String)} is specified, a default login + * page will be generated by the framework. + * + *

Security Filters

+ *

+ * The following Filters are populated + * + *

    + *
  • {@link WebAuthnProcessingFilter}
  • + *
+ * + *

Shared Objects Created

+ *

+ * The following shared objects are populated + *

    + *
  • {@link WebAuthnChallengeRepository}
  • + *
  • {@link WebAuthnServerPropertyProvider}
  • + *
+ * + *

Shared Objects Used

+ *

+ * The following shared objects are used: + * + *

    + *
  • {@link AuthenticationManager}
  • + *
  • {@link MFATokenEvaluator}
  • + *
+ * + * @see WebAuthnAuthenticationProviderConfigurer + */ +public final class WebAuthnLoginConfigurer> extends + AbstractAuthenticationFilterConfigurer, WebAuthnProcessingFilter> { + + //~ Instance fields + // ================================================================================================ + private ObjectMapper jsonMapper = null; + private ObjectMapper cborMapper = null; + private WebAuthnServerPropertyProvider webAuthnServerPropertyProvider = null; + private String usernameParameter = null; + private String passwordParameter = null; + private String credentialIdParameter = null; + private String clientDataJSONParameter = null; + private String authenticatorDataParameter = null; + private String signatureParameter = null; + private String clientExtensionsJSONParameter = null; + + + public WebAuthnLoginConfigurer() { + super(new WebAuthnProcessingFilter(), null); + } + + public static WebAuthnLoginConfigurer webAuthnLogin() { + return new WebAuthnLoginConfigurer<>(); + } + + // ~ Methods + // ======================================================================================================== + @Override + public void init(H http) throws Exception { + super.init(http); + + WebAuthnDataConverter webAuthnDataConverter; + if (jsonMapper == null && cborMapper == null) { + webAuthnDataConverter = WebAuthnConfigurerUtil.getOrCreateWebAuthnDataConverter(http); + } else { + if (jsonMapper == null) { + jsonMapper = new ObjectMapper(); + } + if (cborMapper == null) { + cborMapper = new ObjectMapper(); + } + webAuthnDataConverter = new WebAuthnDataConverter(jsonMapper, cborMapper); + } + http.setSharedObject(WebAuthnDataConverter.class, webAuthnDataConverter); + + if (webAuthnServerPropertyProvider == null) { + webAuthnServerPropertyProvider = WebAuthnConfigurerUtil.getOrCreateServerPropertyProvider(http); + } + http.setSharedObject(WebAuthnServerPropertyProvider.class, webAuthnServerPropertyProvider); + } + + /** + * {@inheritDoc} + */ + @Override + public void configure(H http) throws Exception { + super.configure(http); + configureParameters(); + + this.getAuthenticationFilter().setServerPropertyProvider(webAuthnServerPropertyProvider); + + } + + private void configureParameters() { + if (usernameParameter != null) { + this.getAuthenticationFilter().setUsernameParameter(usernameParameter); + } + if (passwordParameter != null) { + this.getAuthenticationFilter().setPasswordParameter(passwordParameter); + } + if (credentialIdParameter != null) { + this.getAuthenticationFilter().setCredentialIdParameter(credentialIdParameter); + } + if (clientDataJSONParameter != null) { + this.getAuthenticationFilter().setClientDataJSONParameter(clientDataJSONParameter); + } + if (authenticatorDataParameter != null) { + this.getAuthenticationFilter().setAuthenticatorDataParameter(authenticatorDataParameter); + } + if (signatureParameter != null) { + this.getAuthenticationFilter().setSignatureParameter(signatureParameter); + } + if (clientExtensionsJSONParameter != null) { + this.getAuthenticationFilter().setClientExtensionsJSONParameter(clientExtensionsJSONParameter); + } + } + + /** + * Specifies the {@link ObjectMapper} to be used to serialize JSON. + * + * @param jsonMapper the {@link ObjectMapper} + * @return the {@link WebAuthnLoginConfigurer} for additional customization + */ + public WebAuthnLoginConfigurer jsonMapper(ObjectMapper jsonMapper) { + Assert.notNull(jsonMapper, "jsonMapper must not be null"); + this.jsonMapper = jsonMapper; + return this; + } + + /** + * Specifies the {@link ObjectMapper} to be used to serialize CBOR. + * + * @param cborMapper the {@link ObjectMapper} + * @return the {@link WebAuthnLoginConfigurer} for additional customization + */ + public WebAuthnLoginConfigurer cborMapper(ObjectMapper cborMapper) { + Assert.notNull(cborMapper, "cborMapper must not be null"); + this.cborMapper = cborMapper; + return this; + } + + /** + * Specifies the {@link WebAuthnServerPropertyProvider} to be used. + * + * @param webAuthnServerPropertyProvider the {@link WebAuthnServerPropertyProvider} + * @return the {@link WebAuthnLoginConfigurer} for additional customization + */ + public WebAuthnLoginConfigurer serverPropertyProvider(WebAuthnServerPropertyProvider webAuthnServerPropertyProvider) { + Assert.notNull(webAuthnServerPropertyProvider, "webAuthnServerPropertyProvider must not be null"); + this.webAuthnServerPropertyProvider = webAuthnServerPropertyProvider; + return this; + } + + /** + * The HTTP parameter to look for the username when performing authentication. Default + * is "username". + * + * @param usernameParameter the HTTP parameter to look for the username when + * performing authentication + * @return the {@link FormLoginConfigurer} for additional customization + */ + public WebAuthnLoginConfigurer usernameParameter(String usernameParameter) { + Assert.hasText(usernameParameter, "usernameParameter must not be null or empty"); + this.usernameParameter = usernameParameter; + return this; + } + + /** + * The HTTP parameter to look for the password when performing authentication. Default + * is "password". + * + * @param passwordParameter the HTTP parameter to look for the password when + * performing authentication + * @return the {@link WebAuthnLoginConfigurer} for additional customization + */ + public WebAuthnLoginConfigurer passwordParameter(String passwordParameter) { + Assert.hasText(passwordParameter, "passwordParameter must not be null or empty"); + this.passwordParameter = passwordParameter; + return this; + } + + /** + * The HTTP parameter to look for the credentialId when performing authentication. Default + * is "credentialId". + * + * @param credentialIdParameter the HTTP parameter to look for the credentialId when + * performing authentication + * @return the {@link WebAuthnLoginConfigurer} for additional customization + */ + public WebAuthnLoginConfigurer credentialIdParameter(String credentialIdParameter) { + Assert.hasText(credentialIdParameter, "credentialIdParameter must not be null or empty"); + this.credentialIdParameter = credentialIdParameter; + return this; + } + + /** + * The HTTP parameter to look for the clientData when performing authentication. Default + * is "clientDataJSON". + * + * @param clientDataJSONParameter the HTTP parameter to look for the clientDataJSON when + * performing authentication + * @return the {@link WebAuthnLoginConfigurer} for additional customization + */ + public WebAuthnLoginConfigurer clientDataJSONParameter(String clientDataJSONParameter) { + Assert.hasText(clientDataJSONParameter, "clientDataJSONParameter must not be null or empty"); + this.clientDataJSONParameter = clientDataJSONParameter; + return this; + } + + /** + * The HTTP parameter to look for the authenticatorData when performing authentication. Default + * is "authenticatorData". + * + * @param authenticatorDataParameter the HTTP parameter to look for the authenticatorData when + * performing authentication + * @return the {@link WebAuthnLoginConfigurer} for additional customization + */ + public WebAuthnLoginConfigurer authenticatorDataParameter(String authenticatorDataParameter) { + Assert.hasText(authenticatorDataParameter, "authenticatorDataParameter must not be null or empty"); + this.authenticatorDataParameter = authenticatorDataParameter; + return this; + } + + /** + * The HTTP parameter to look for the signature when performing authentication. Default + * is "signature". + * + * @param signatureParameter the HTTP parameter to look for the signature when + * performing authentication + * @return the {@link WebAuthnLoginConfigurer} for additional customization + */ + public WebAuthnLoginConfigurer signatureParameter(String signatureParameter) { + Assert.hasText(signatureParameter, "signatureParameter must not be null or empty"); + this.signatureParameter = signatureParameter; + return this; + } + + /** + * The HTTP parameter to look for the clientExtensionsJSON when performing authentication. Default + * is "clientExtensionsJSON". + * + * @param clientExtensionsJSONParameter the HTTP parameter to look for the clientExtensionsJSON when + * performing authentication + * @return the {@link WebAuthnLoginConfigurer} for additional customization + */ + public WebAuthnLoginConfigurer clientExtensionsJSONParameter(String clientExtensionsJSONParameter) { + Assert.hasText(clientExtensionsJSONParameter, "clientExtensionsJSONParameter must not be null or empty"); + this.clientExtensionsJSONParameter = clientExtensionsJSONParameter; + return this; + } + + /** + * Forward Authentication Success Handler + * + * @param forwardUrl the target URL in case of success + * @return he {@link WebAuthnLoginConfigurer} for additional customization + */ + public WebAuthnLoginConfigurer successForwardUrl(String forwardUrl) { + successHandler(new ForwardAuthenticationSuccessHandler(forwardUrl)); + return this; + } + + /** + * Forward Authentication Failure Handler + * + * @param forwardUrl the target URL in case of failure + * @return he {@link WebAuthnLoginConfigurer} for additional customization + */ + public WebAuthnLoginConfigurer failureForwardUrl(String forwardUrl) { + failureHandler(new ForwardAuthenticationFailureHandler(forwardUrl)); + return this; + } + + /** + *

+ * Specifies the URL to send users to if login is required. If used with + * {@link WebSecurityConfigurerAdapter} a default login page will be generated when + * this attribute is not specified. + *

+ * + * @param loginPage login page + * @return the {@link WebAuthnLoginConfigurer} for additional customization + */ + @Override + public WebAuthnLoginConfigurer loginPage(String loginPage) { + return super.loginPage(loginPage); + } + + /** + * Create the {@link RequestMatcher} given a loginProcessingUrl + * + * @param loginProcessingUrl creates the {@link RequestMatcher} based upon the + * loginProcessingUrl + * @return the {@link RequestMatcher} to use based upon the loginProcessingUrl + */ + @Override + protected RequestMatcher createLoginProcessingUrlMatcher(String loginProcessingUrl) { + return new AntPathRequestMatcher(loginProcessingUrl, "POST"); + } + + +} diff --git a/webauthn/src/main/java/org/springframework/security/webauthn/exception/CredentialIdNotFoundException.java b/webauthn/src/main/java/org/springframework/security/webauthn/exception/CredentialIdNotFoundException.java new file mode 100644 index 00000000000..babb04630ec --- /dev/null +++ b/webauthn/src/main/java/org/springframework/security/webauthn/exception/CredentialIdNotFoundException.java @@ -0,0 +1,30 @@ +/* + * Copyright 2002-2019 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.webauthn.exception; + +/** + * Thrown if an authentication request is rejected because credentialId is not found. + */ +public class CredentialIdNotFoundException extends WebAuthnAuthenticationException { + public CredentialIdNotFoundException(String message) { + super(message); + } + + public CredentialIdNotFoundException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/webauthn/src/main/java/org/springframework/security/webauthn/exception/WebAuthnAuthenticationException.java b/webauthn/src/main/java/org/springframework/security/webauthn/exception/WebAuthnAuthenticationException.java new file mode 100644 index 00000000000..425af670bec --- /dev/null +++ b/webauthn/src/main/java/org/springframework/security/webauthn/exception/WebAuthnAuthenticationException.java @@ -0,0 +1,36 @@ +/* + * Copyright 2002-2019 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.webauthn.exception; + +import org.springframework.security.core.AuthenticationException; + +/** + * A specialized {@link AuthenticationException} for WebAuthn + * + * @author Yoshikazu Nojima + */ +public class WebAuthnAuthenticationException extends AuthenticationException { + + public WebAuthnAuthenticationException(String message, Throwable cause) { + super(message, cause); + } + + public WebAuthnAuthenticationException(String message) { + super(message); + } + +} diff --git a/webauthn/src/main/java/org/springframework/security/webauthn/server/EffectiveRpIdProvider.java b/webauthn/src/main/java/org/springframework/security/webauthn/server/EffectiveRpIdProvider.java new file mode 100644 index 00000000000..a6599c8f4ba --- /dev/null +++ b/webauthn/src/main/java/org/springframework/security/webauthn/server/EffectiveRpIdProvider.java @@ -0,0 +1,30 @@ +/* + * Copyright 2002-2019 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.webauthn.server; + +import javax.servlet.http.HttpServletRequest; + +public interface EffectiveRpIdProvider { + + /** + * returns effective rpId based on request origin and configured rpId. + * + * @param request request + * @return effective rpId + */ + String getEffectiveRpId(HttpServletRequest request); +} diff --git a/webauthn/src/main/java/org/springframework/security/webauthn/server/WebAuthnServerPropertyProvider.java b/webauthn/src/main/java/org/springframework/security/webauthn/server/WebAuthnServerPropertyProvider.java new file mode 100644 index 00000000000..51510e82a9f --- /dev/null +++ b/webauthn/src/main/java/org/springframework/security/webauthn/server/WebAuthnServerPropertyProvider.java @@ -0,0 +1,39 @@ +/* + * Copyright 2002-2019 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.webauthn.server; + + +import com.webauthn4j.server.ServerProperty; + +import javax.servlet.http.HttpServletRequest; + +/** + * Provides {@link ServerProperty} instance associated with {@link HttpServletRequest} + * + * @author Yoshikazu Nojima + */ +public interface WebAuthnServerPropertyProvider { + + /** + * Provides {@link ServerProperty} + * + * @param request http servlet request + * @return the {@link ServerProperty} + */ + ServerProperty provide(HttpServletRequest request); + +} diff --git a/webauthn/src/main/java/org/springframework/security/webauthn/server/WebAuthnServerPropertyProviderImpl.java b/webauthn/src/main/java/org/springframework/security/webauthn/server/WebAuthnServerPropertyProviderImpl.java new file mode 100644 index 00000000000..2cd03fc9948 --- /dev/null +++ b/webauthn/src/main/java/org/springframework/security/webauthn/server/WebAuthnServerPropertyProviderImpl.java @@ -0,0 +1,59 @@ +/* + * Copyright 2002-2019 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.webauthn.server; + +import com.webauthn4j.data.client.Origin; +import com.webauthn4j.data.client.challenge.Challenge; +import com.webauthn4j.server.ServerProperty; +import org.springframework.security.webauthn.challenge.WebAuthnChallengeRepository; +import org.springframework.util.Assert; + +import javax.servlet.ServletRequest; +import javax.servlet.http.HttpServletRequest; + +/** + * {@inheritDoc} + */ +public class WebAuthnServerPropertyProviderImpl implements WebAuthnServerPropertyProvider { + + //~ Instance fields + // ================================================================================================ + private EffectiveRpIdProvider effectiveRpIdProvider; + private WebAuthnChallengeRepository webAuthnChallengeRepository; + + public WebAuthnServerPropertyProviderImpl(EffectiveRpIdProvider effectiveRpIdProvider, WebAuthnChallengeRepository webAuthnChallengeRepository) { + + Assert.notNull(effectiveRpIdProvider, "effectiveRpIdProvider must not be null"); + Assert.notNull(webAuthnChallengeRepository, "webAuthnChallengeRepository must not be null"); + + this.effectiveRpIdProvider = effectiveRpIdProvider; + this.webAuthnChallengeRepository = webAuthnChallengeRepository; + } + + public ServerProperty provide(HttpServletRequest request) { + + Origin origin = createOrigin(request); + String effectiveRpId = effectiveRpIdProvider.getEffectiveRpId(request); + Challenge challenge = webAuthnChallengeRepository.loadOrGenerateChallenge(request); + + return new ServerProperty(origin, effectiveRpId, challenge, null); // tokenBinding is not supported by Servlet API as of 4.0 + } + + private static Origin createOrigin(ServletRequest request) { + return new Origin(request.getScheme(), request.getServerName(), request.getServerPort()); + } +} diff --git a/webauthn/src/main/java/org/springframework/security/webauthn/userdetails/InMemoryWebAuthnAndPasswordUserDetailsManager.java b/webauthn/src/main/java/org/springframework/security/webauthn/userdetails/InMemoryWebAuthnAndPasswordUserDetailsManager.java new file mode 100644 index 00000000000..7f4bd4bdad2 --- /dev/null +++ b/webauthn/src/main/java/org/springframework/security/webauthn/userdetails/InMemoryWebAuthnAndPasswordUserDetailsManager.java @@ -0,0 +1,107 @@ +/* + * Copyright 2002-2019 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.webauthn.userdetails; + +import com.webauthn4j.util.Base64UrlUtil; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.security.webauthn.authenticator.WebAuthnAuthenticator; +import org.springframework.security.webauthn.authenticator.WebAuthnAuthenticatorService; +import org.springframework.security.webauthn.exception.CredentialIdNotFoundException; +import org.springframework.util.Assert; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.Map; + +/** + * {@inheritDoc} + */ +public class InMemoryWebAuthnAndPasswordUserDetailsManager implements WebAuthnAndPasswordUserDetailsService, WebAuthnAuthenticatorService { + + private Map users = new HashMap<>(); + + /** + * {@inheritDoc} + */ + @Override + public WebAuthnAndPasswordUserDetails loadUserByUsername(String username) { + WebAuthnAndPasswordUserDetails userDetails = users.get(username); + if (userDetails == null) { + throw new UsernameNotFoundException(String.format("UserEntity with username'%s' is not found.", username)); + } + return clone(userDetails); } + + /** + * {@inheritDoc} + */ + @Override + public WebAuthnAndPasswordUserDetails loadWebAuthnUserByUsername(String username) { + WebAuthnAndPasswordUserDetails userDetails = loadUserByUsername(username); + return clone(userDetails); + } + + /** + * {@inheritDoc} + */ + @Override + @SuppressWarnings("squid:RedundantThrowsDeclarationCheck") + public WebAuthnAndPasswordUserDetails loadWebAuthnUserByCredentialId(byte[] credentialId) throws CredentialIdNotFoundException { + WebAuthnAndPasswordUserDetails userDetails = users + .entrySet() + .stream() + .filter(entry -> entry.getValue().getAuthenticators().stream().anyMatch(authenticator -> Arrays.equals(authenticator.getCredentialId(), credentialId))) + .findFirst() + .orElseThrow(() -> new CredentialIdNotFoundException(String.format("AuthenticatorEntity with credentialId'%s' is not found.", Base64UrlUtil.encodeToString(credentialId)))) + .getValue(); + return clone(userDetails); + } + + public void createUser(WebAuthnAndPasswordUserDetails user) { + Assert.isTrue(!userExists(user.getUsername()), "user should not exist"); + users.put(user.getUsername(), user); + } + + public void deleteUser(String username) { + users.remove(username); + } + + public boolean userExists(String username) { + WebAuthnAndPasswordUserDetails userDetails = users.get(username); + return userDetails != null; + } + + @Override + @SuppressWarnings("squid:RedundantThrowsDeclarationCheck") + public void updateCounter(byte[] credentialId, long counter) throws CredentialIdNotFoundException { + + WebAuthnAuthenticator authenticator = users + .entrySet() + .stream() + .flatMap(entry -> entry.getValue().getAuthenticators().stream()) + .filter(entry -> Arrays.equals(entry.getCredentialId(), credentialId)) + .findFirst() + .orElseThrow(() -> new CredentialIdNotFoundException(String.format("AuthenticatorEntity with credentialId'%s' is not found.", Base64UrlUtil.encodeToString(credentialId)))); + + authenticator.setCounter(counter); + } + + private WebAuthnAndPasswordUserDetails clone(WebAuthnAndPasswordUserDetails original){ + return new WebAuthnAndPasswordUser(original.getUserHandle(), original.getUsername(), original.getPassword(), new ArrayList<>(original.getAuthenticators()), original.getAuthorities()); + } + +} diff --git a/webauthn/src/main/java/org/springframework/security/webauthn/userdetails/WebAuthnAndPasswordUser.java b/webauthn/src/main/java/org/springframework/security/webauthn/userdetails/WebAuthnAndPasswordUser.java new file mode 100644 index 00000000000..8bb3ee589bc --- /dev/null +++ b/webauthn/src/main/java/org/springframework/security/webauthn/userdetails/WebAuthnAndPasswordUser.java @@ -0,0 +1,85 @@ +/* + * Copyright 2002-2019 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.webauthn.userdetails; + +import com.webauthn4j.util.ArrayUtil; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.userdetails.User; +import org.springframework.security.webauthn.authenticator.WebAuthnAuthenticator; + +import java.util.Collection; +import java.util.Collections; +import java.util.List; + +/** + * A {@link WebAuthnAndPasswordUserDetails} implementation + * + * @author Yoshikazu Nojima + */ +@SuppressWarnings("squid:S2160") +public class WebAuthnAndPasswordUser extends User implements WebAuthnAndPasswordUserDetails { + + // ~ Instance fields + // ================================================================================================ + private boolean singleFactorAuthenticationAllowed; + private byte[] userHandle; + private List authenticators; + + public WebAuthnAndPasswordUser( + byte[] userHandle, String username, String password, List authenticators, + Collection authorities) { + this(userHandle, username, password, authenticators, false, true, true, true, true, authorities); + } + + public WebAuthnAndPasswordUser(byte[] userHandle, String username, String password, List authenticators, boolean singleFactorAuthenticationAllowed, List authorities) { + this(userHandle, username, password, authenticators, singleFactorAuthenticationAllowed, true, true, true, true, authorities); + } + + @SuppressWarnings("squid:S00107") + public WebAuthnAndPasswordUser( + byte[] userHandle, String username, String password, List authenticators, boolean singleFactorAuthenticationAllowed, boolean enabled, boolean accountNonExpired, + boolean credentialsNonExpired, boolean accountNonLocked, + Collection authorities) { + super(username, password, enabled, accountNonExpired, credentialsNonExpired, accountNonLocked, authorities); + this.userHandle = ArrayUtil.clone(userHandle); + this.authenticators = Collections.unmodifiableList(authenticators); + this.singleFactorAuthenticationAllowed = singleFactorAuthenticationAllowed; + } + + @Override + public byte[] getUserHandle() { + return ArrayUtil.clone(userHandle); + } + + @Override + public List getAuthenticators() { + return this.authenticators; + } + + @Override + public boolean isSingleFactorAuthenticationAllowed() { + return singleFactorAuthenticationAllowed; + } + + @Override + public void setSingleFactorAuthenticationAllowed(boolean singleFactorAuthenticationAllowed) { + this.singleFactorAuthenticationAllowed = singleFactorAuthenticationAllowed; + } + + +} + diff --git a/webauthn/src/main/java/org/springframework/security/webauthn/userdetails/WebAuthnAndPasswordUserDetails.java b/webauthn/src/main/java/org/springframework/security/webauthn/userdetails/WebAuthnAndPasswordUserDetails.java new file mode 100644 index 00000000000..f4e59ca1804 --- /dev/null +++ b/webauthn/src/main/java/org/springframework/security/webauthn/userdetails/WebAuthnAndPasswordUserDetails.java @@ -0,0 +1,27 @@ +/* + * Copyright 2002-2019 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.webauthn.userdetails; + +import org.springframework.security.core.userdetails.MFAUserDetails; + +public interface WebAuthnAndPasswordUserDetails extends WebAuthnUserDetails, MFAUserDetails { + + boolean isSingleFactorAuthenticationAllowed(); + + void setSingleFactorAuthenticationAllowed(boolean singleFactorAuthenticationAllowed); + +} diff --git a/webauthn/src/main/java/org/springframework/security/webauthn/userdetails/WebAuthnAndPasswordUserDetailsService.java b/webauthn/src/main/java/org/springframework/security/webauthn/userdetails/WebAuthnAndPasswordUserDetailsService.java new file mode 100644 index 00000000000..8fe9840437e --- /dev/null +++ b/webauthn/src/main/java/org/springframework/security/webauthn/userdetails/WebAuthnAndPasswordUserDetailsService.java @@ -0,0 +1,22 @@ +/* + * Copyright 2002-2019 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.webauthn.userdetails; + +import org.springframework.security.core.userdetails.UserDetailsService; + +public interface WebAuthnAndPasswordUserDetailsService extends UserDetailsService, WebAuthnUserDetailsService { +} diff --git a/webauthn/src/main/java/org/springframework/security/webauthn/userdetails/WebAuthnUser.java b/webauthn/src/main/java/org/springframework/security/webauthn/userdetails/WebAuthnUser.java new file mode 100644 index 00000000000..a378963024c --- /dev/null +++ b/webauthn/src/main/java/org/springframework/security/webauthn/userdetails/WebAuthnUser.java @@ -0,0 +1,168 @@ +/* + * Copyright 2002-2019 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.webauthn.userdetails; + +import com.webauthn4j.util.ArrayUtil; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.SpringSecurityCoreVersion; +import org.springframework.security.webauthn.authenticator.WebAuthnAuthenticator; +import org.springframework.util.Assert; + +import java.io.Serializable; +import java.util.*; + +/** + * A {@link WebAuthnUserDetails} implementation + * + * @author Yoshikazu Nojima + */ +@SuppressWarnings("squid:S2160") +public class WebAuthnUser implements WebAuthnUserDetails { + + private final String username; + private final Set authorities; + private final boolean accountNonExpired; + private final boolean accountNonLocked; + private final boolean credentialsNonExpired; + private final boolean enabled; + // ~ Instance fields + // ================================================================================================ + private byte[] userHandle; + private List authenticators; + + public WebAuthnUser( + byte[] userHandle, String username, List authenticators, + Collection authorities) { + this(userHandle, username, authenticators, + true, true, true, true, + authorities); + } + + @SuppressWarnings("squid:S00107") + public WebAuthnUser( + byte[] userHandle, String username, List authenticators, boolean enabled, boolean accountNonExpired, + boolean credentialsNonExpired, boolean accountNonLocked, Collection authorities) { + this.userHandle = ArrayUtil.clone(userHandle); + this.username = username; + this.authenticators = authenticators; + this.enabled = enabled; + this.accountNonExpired = accountNonExpired; + this.credentialsNonExpired = credentialsNonExpired; + this.accountNonLocked = accountNonLocked; + this.authorities = Collections.unmodifiableSet(sortAuthorities(authorities)); + } + + private static SortedSet sortAuthorities( + Collection authorities) { + Assert.notNull(authorities, "Cannot pass a null GrantedAuthority collection"); + // Ensure array iteration order is predictable (as per + // UserDetails.getAuthorities() contract and SEC-717) + SortedSet sortedAuthorities = new TreeSet<>( + new AuthorityComparator()); + + for (GrantedAuthority grantedAuthority : authorities) { + Assert.notNull(grantedAuthority, + "GrantedAuthority list cannot contain any null elements"); + sortedAuthorities.add(grantedAuthority); + } + + return sortedAuthorities; + } + + @Override + public byte[] getUserHandle() { + return ArrayUtil.clone(userHandle); + } + + @Override + public String getUsername() { + return username; + } + + @Override + public List getAuthenticators() { + return authenticators; + } + + @Override + public Set getAuthorities() { + return authorities; + } + + @Override + public boolean isAccountNonExpired() { + return accountNonExpired; + } + + @Override + public boolean isAccountNonLocked() { + return accountNonLocked; + } + + @Override + public boolean isCredentialsNonExpired() { + return credentialsNonExpired; + } + + @Override + public boolean isEnabled() { + return enabled; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + WebAuthnUser that = (WebAuthnUser) o; + return accountNonExpired == that.accountNonExpired && + accountNonLocked == that.accountNonLocked && + credentialsNonExpired == that.credentialsNonExpired && + enabled == that.enabled && + Arrays.equals(userHandle, that.userHandle) && + Objects.equals(username, that.username) && + Objects.equals(authenticators, that.authenticators) && + Objects.equals(authorities, that.authorities); + } + + @Override + public int hashCode() { + int result = Objects.hash(username, authenticators, authorities, accountNonExpired, accountNonLocked, credentialsNonExpired, enabled); + result = 31 * result + Arrays.hashCode(userHandle); + return result; + } + + private static class AuthorityComparator implements Comparator, + Serializable { + private static final long serialVersionUID = SpringSecurityCoreVersion.SERIAL_VERSION_UID; + + public int compare(GrantedAuthority g1, GrantedAuthority g2) { + // Neither should ever be null as each entry is checked before adding it to + // the set. + // If the authority is null, it is a custom authority and should precede + // others. + if (g2.getAuthority() == null) { + return -1; + } + + if (g1.getAuthority() == null) { + return 1; + } + + return g1.getAuthority().compareTo(g2.getAuthority()); + } + } +} diff --git a/webauthn/src/main/java/org/springframework/security/webauthn/userdetails/WebAuthnUserDetails.java b/webauthn/src/main/java/org/springframework/security/webauthn/userdetails/WebAuthnUserDetails.java new file mode 100644 index 00000000000..66e2fde3b2c --- /dev/null +++ b/webauthn/src/main/java/org/springframework/security/webauthn/userdetails/WebAuthnUserDetails.java @@ -0,0 +1,98 @@ +/* + * Copyright 2002-2019 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.webauthn.userdetails; + +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.webauthn.authenticator.WebAuthnAuthenticator; + +import java.io.Serializable; +import java.util.Collection; + +/** + * An extended {@link UserDetails} interface for WebAuthn + * + * @author Yoshikazu Nojima + */ +public interface WebAuthnUserDetails extends Serializable { + // ~ Methods + // ======================================================================================================== + + /** + * Returns the authorities granted to the user. Cannot return null. + * + * @return the authorities, sorted by natural key (never null) + */ + Collection getAuthorities(); + + /** + * Returns the userHandle that identifies the user. Cannot return null. + * + * @return the userHandle (never null) + */ + byte[] getUserHandle(); + + /** + * Returns the username used to authenticate the user. Cannot return null. + * + * @return the username (never null) + */ + String getUsername(); + + /** + * Returns the authenticators used to authenticate the user. + * + * @return the authenticators + */ + @SuppressWarnings("squid:S1452") + Collection getAuthenticators(); + + /** + * Indicates whether the user's account has expired. An expired account cannot be + * authenticated. + * + * @return true if the user's account is valid (ie non-expired), + * false if no longer valid (ie expired) + */ + boolean isAccountNonExpired(); + + /** + * Indicates whether the user is locked or unlocked. A locked user cannot be + * authenticated. + * + * @return true if the user is not locked, false otherwise + */ + boolean isAccountNonLocked(); + + /** + * Indicates whether the user's credentials (password) has expired. Expired + * credentials prevent authentication. + * + * @return true if the user's credentials are valid (ie non-expired), + * false if no longer valid (ie expired) + */ + boolean isCredentialsNonExpired(); + + /** + * Indicates whether the user is enabled or disabled. A disabled user cannot be + * authenticated. + * + * @return true if the user is enabled, false otherwise + */ + boolean isEnabled(); + +} diff --git a/webauthn/src/main/java/org/springframework/security/webauthn/userdetails/WebAuthnUserDetailsChecker.java b/webauthn/src/main/java/org/springframework/security/webauthn/userdetails/WebAuthnUserDetailsChecker.java new file mode 100644 index 00000000000..dc1e156c2fc --- /dev/null +++ b/webauthn/src/main/java/org/springframework/security/webauthn/userdetails/WebAuthnUserDetailsChecker.java @@ -0,0 +1,30 @@ +/* + * Copyright 2002-2016 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.webauthn.userdetails; + +import org.springframework.security.core.userdetails.UserDetailsChecker; + +/** + * A {@link WebAuthnUserDetails} equivalent of {@link UserDetailsChecker} + */ +public interface WebAuthnUserDetailsChecker { + /** + * Examines the User + * + * @param toCheck the UserDetails instance whose status should be checked. + */ + void check(WebAuthnUserDetails toCheck); +} diff --git a/webauthn/src/main/java/org/springframework/security/webauthn/userdetails/WebAuthnUserDetailsService.java b/webauthn/src/main/java/org/springframework/security/webauthn/userdetails/WebAuthnUserDetailsService.java new file mode 100644 index 00000000000..a3545feb16b --- /dev/null +++ b/webauthn/src/main/java/org/springframework/security/webauthn/userdetails/WebAuthnUserDetailsService.java @@ -0,0 +1,50 @@ +/* + * Copyright 2002-2019 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.webauthn.userdetails; + +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.security.webauthn.exception.CredentialIdNotFoundException; + +/** + * Specialized {@link UserDetailsService} for WebAuthn + * + * @author Yoshikazu Nojima + */ +public interface WebAuthnUserDetailsService { + + /** + * Locates a user based on the username. + * + * @param username the username identifying the user whose data is required + * @return a fully populated {@link WebAuthnUserDetails} instance (never null) + * @throws UsernameNotFoundException if the user could not be found + */ + @SuppressWarnings("squid:RedundantThrowsDeclarationCheck") + WebAuthnUserDetails loadWebAuthnUserByUsername(String username) throws UsernameNotFoundException; + + /** + * Locates a user based on the credentialId. + * + * @param credentialId credentialId + * @return fully populated {@link WebAuthnUserDetails} instance (never null), + * which must returns the authenticator in getAuthenticators result. + * @throws CredentialIdNotFoundException if the authenticator could not be found + */ + @SuppressWarnings("squid:RedundantThrowsDeclarationCheck") + WebAuthnUserDetails loadWebAuthnUserByCredentialId(byte[] credentialId) throws CredentialIdNotFoundException; +}