diff --git a/build.gradle b/build.gradle index 0c97e2f38..aac7d0e02 100644 --- a/build.gradle +++ b/build.gradle @@ -38,6 +38,7 @@ configure(moduleProjects) { mavenBom "io.projectreactor:reactor-bom:2020.0.7" mavenBom "org.springframework:spring-framework-bom:5.3.7" mavenBom "org.springframework.data:spring-data-bom:2021.0.1" + mavenBom "org.springframework.security:spring-security-bom:5.5.0" mavenBom "org.junit:junit-bom:5.7.2" } dependencies { diff --git a/samples/webflux-security/src/main/java/io/spring/sample/graphql/SecurityConfig.java b/samples/webflux-security/src/main/java/io/spring/sample/graphql/SecurityConfig.java index 9fc1ca751..03be4d818 100644 --- a/samples/webflux-security/src/main/java/io/spring/sample/graphql/SecurityConfig.java +++ b/samples/webflux-security/src/main/java/io/spring/sample/graphql/SecurityConfig.java @@ -17,6 +17,7 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.graphql.security.ReactiveSecurityDataFetcherExceptionResolver; import org.springframework.security.config.annotation.method.configuration.EnableReactiveMethodSecurity; import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity; import org.springframework.security.config.web.server.ServerHttpSecurity; @@ -52,4 +53,9 @@ public MapReactiveUserDetailsService userDetailsService() { return new MapReactiveUserDetailsService(rob, admin); } + @Bean + public ReactiveSecurityDataFetcherExceptionResolver dataFetcherExceptionResolver() { + return new ReactiveSecurityDataFetcherExceptionResolver(); + } + } diff --git a/samples/webflux-security/src/main/java/io/spring/sample/graphql/SecurityDataFetcherExceptionResolver.java b/samples/webflux-security/src/main/java/io/spring/sample/graphql/SecurityDataFetcherExceptionResolver.java deleted file mode 100644 index 5e0c5abf9..000000000 --- a/samples/webflux-security/src/main/java/io/spring/sample/graphql/SecurityDataFetcherExceptionResolver.java +++ /dev/null @@ -1,82 +0,0 @@ -/* - * Copyright 2002-2021 the original author or authors. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package io.spring.sample.graphql; - -import java.util.Arrays; -import java.util.Collections; -import java.util.List; - -import graphql.GraphQLError; -import graphql.GraphqlErrorBuilder; -import graphql.schema.DataFetchingEnvironment; -import org.springframework.util.Assert; -import reactor.core.publisher.Mono; - -import org.springframework.graphql.execution.DataFetcherExceptionResolver; -import org.springframework.graphql.execution.ErrorType; -import org.springframework.security.access.AccessDeniedException; -import org.springframework.security.authentication.AuthenticationTrustResolver; -import org.springframework.security.authentication.AuthenticationTrustResolverImpl; -import org.springframework.security.core.AuthenticationException; -import org.springframework.security.core.context.ReactiveSecurityContextHolder; -import org.springframework.security.core.context.SecurityContext; -import org.springframework.stereotype.Component; - -// @formatter:off - -@Component -public class SecurityDataFetcherExceptionResolver implements DataFetcherExceptionResolver { - - private AuthenticationTrustResolver authenticationTrustResolver = new AuthenticationTrustResolverImpl(); - - - @Override - public Mono> resolveException(Throwable exception, DataFetchingEnvironment environment) { - if (exception instanceof AuthenticationException) { - return unauthorized(environment); - } - if (exception instanceof AccessDeniedException) { - return ReactiveSecurityContextHolder.getContext() - .map(SecurityContext::getAuthentication) - .filter(a -> !this.authenticationTrustResolver.isAnonymous(a)) - .flatMap(anonymous -> forbidden(environment)) - .switchIfEmpty(unauthorized(environment)); - } - return Mono.empty(); - } - - public void setAuthenticationTrustResolver(AuthenticationTrustResolver authenticationTrustResolver) { - Assert.notNull(authenticationTrustResolver, "authenticationTrustResolver cannot be null"); - this.authenticationTrustResolver = authenticationTrustResolver; - } - - private Mono> unauthorized(DataFetchingEnvironment environment) { - return Mono.fromCallable(() -> Collections.singletonList( - GraphqlErrorBuilder.newError(environment) - .errorType(ErrorType.UNAUTHORIZED) - .message("Unauthorized") - .build())); - } - - private Mono> forbidden(DataFetchingEnvironment environment) { - return Mono.fromCallable(() -> Collections.singletonList( - GraphqlErrorBuilder.newError(environment) - .errorType(ErrorType.FORBIDDEN) - .message("Forbidden") - .build())); - } - -} diff --git a/samples/webmvc-http-security/src/main/java/io/spring/sample/graphql/SampleApplication.java b/samples/webmvc-http-security/src/main/java/io/spring/sample/graphql/SampleApplication.java index 19225fa66..a2fae4bb4 100644 --- a/samples/webmvc-http-security/src/main/java/io/spring/sample/graphql/SampleApplication.java +++ b/samples/webmvc-http-security/src/main/java/io/spring/sample/graphql/SampleApplication.java @@ -16,8 +16,14 @@ package io.spring.sample.graphql; +import java.time.Duration; + +import reactor.core.publisher.Mono; + import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.context.annotation.Bean; +import org.springframework.graphql.web.WebInterceptor; @SpringBootApplication public class SampleApplication { @@ -25,4 +31,13 @@ public class SampleApplication { public static void main(String[] args) { SpringApplication.run(SampleApplication.class, args); } + + @Bean + public WebInterceptor interceptor() { + return (input, next) -> { + // Switch threads to prove ThreadLocal context propagation works + return Mono.delay(Duration.ofMillis(10)).flatMap(aLong -> next.handle(input)); + }; + } + } diff --git a/samples/webmvc-http-security/src/main/java/io/spring/sample/graphql/SecurityConfig.java b/samples/webmvc-http-security/src/main/java/io/spring/sample/graphql/SecurityConfig.java index 7a51dcc2a..c9a9b081c 100644 --- a/samples/webmvc-http-security/src/main/java/io/spring/sample/graphql/SecurityConfig.java +++ b/samples/webmvc-http-security/src/main/java/io/spring/sample/graphql/SecurityConfig.java @@ -2,6 +2,9 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.graphql.execution.ThreadLocalAccessor; +import org.springframework.graphql.security.SecurityContextThreadLocalAccessor; +import org.springframework.graphql.security.SecurityDataFetcherExceptionResolver; import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; @@ -38,4 +41,14 @@ public static InMemoryUserDetailsManager userDetailsService() { return new InMemoryUserDetailsManager(rob, admin); } + @Bean + public SecurityDataFetcherExceptionResolver dataFetcherExceptionResolver() { + return new SecurityDataFetcherExceptionResolver(); + } + + @Bean + public ThreadLocalAccessor threadLocalAccessor() { + return new SecurityContextThreadLocalAccessor(); + } + } diff --git a/samples/webmvc-http-security/src/main/java/io/spring/sample/graphql/SecurityDataFetcherExceptionResolver.java b/samples/webmvc-http-security/src/main/java/io/spring/sample/graphql/SecurityDataFetcherExceptionResolver.java deleted file mode 100644 index e9b52a8b7..000000000 --- a/samples/webmvc-http-security/src/main/java/io/spring/sample/graphql/SecurityDataFetcherExceptionResolver.java +++ /dev/null @@ -1,59 +0,0 @@ -package io.spring.sample.graphql; - -import graphql.GraphQLError; -import graphql.GraphqlErrorBuilder; -import graphql.schema.DataFetchingEnvironment; - -import org.springframework.graphql.execution.DataFetcherExceptionResolverAdapter; -import org.springframework.graphql.execution.ErrorType; -import org.springframework.security.access.AccessDeniedException; -import org.springframework.security.authentication.AuthenticationTrustResolver; -import org.springframework.security.authentication.AuthenticationTrustResolverImpl; -import org.springframework.security.core.Authentication; -import org.springframework.security.core.AuthenticationException; -import org.springframework.security.core.context.SecurityContext; -import org.springframework.security.core.context.SecurityContextHolder; -import org.springframework.stereotype.Component; -import org.springframework.util.Assert; - -@Component -public class SecurityDataFetcherExceptionResolver extends DataFetcherExceptionResolverAdapter { - - private AuthenticationTrustResolver authenticationTrustResolver = new AuthenticationTrustResolverImpl(); - - @Override - protected GraphQLError resolveToSingleError(Throwable ex, DataFetchingEnvironment env) { - if (ex instanceof AuthenticationException) { - return unauthorized(env); - } - if (ex instanceof AccessDeniedException) { - SecurityContext context = SecurityContextHolder.getContext(); - Authentication authentication = context.getAuthentication(); - if (this.authenticationTrustResolver.isAnonymous(authentication)) { - return unauthorized(env); - } - return forbidden(env); - } - return null; - } - - public void setAuthenticationTrustResolver(AuthenticationTrustResolver authenticationTrustResolver) { - Assert.notNull(authenticationTrustResolver, "authenticationTrustResolver cannot be null"); - this.authenticationTrustResolver = authenticationTrustResolver; - } - - private GraphQLError unauthorized(DataFetchingEnvironment environment) { - return GraphqlErrorBuilder.newError(environment) - .errorType(ErrorType.UNAUTHORIZED) - .message("Unauthorized") - .build(); - } - - private GraphQLError forbidden(DataFetchingEnvironment environment) { - return GraphqlErrorBuilder.newError(environment) - .errorType(ErrorType.FORBIDDEN) - .message("Forbidden") - .build(); - } - -} diff --git a/spring-graphql/build.gradle b/spring-graphql/build.gradle index ec37410d3..5be86ae5b 100644 --- a/spring-graphql/build.gradle +++ b/spring-graphql/build.gradle @@ -11,6 +11,8 @@ dependencies { compileOnly 'org.springframework:spring-websocket' compileOnly 'javax.servlet:javax.servlet-api:4.0.1' + compileOnly 'org.springframework.security:spring-security-core' + compileOnly 'com.querydsl:querydsl-core:4.4.0' compileOnly 'org.springframework.data:spring-data-commons' diff --git a/spring-graphql/src/main/java/org/springframework/graphql/security/ExceptionResolverDelegate.java b/spring-graphql/src/main/java/org/springframework/graphql/security/ExceptionResolverDelegate.java new file mode 100644 index 000000000..ea523aa1c --- /dev/null +++ b/spring-graphql/src/main/java/org/springframework/graphql/security/ExceptionResolverDelegate.java @@ -0,0 +1,60 @@ +/* + * Copyright 2002-2021 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.graphql.security; + +import graphql.GraphQLError; +import graphql.GraphqlErrorBuilder; +import graphql.schema.DataFetchingEnvironment; + +import org.springframework.graphql.execution.ErrorType; +import org.springframework.security.authentication.AuthenticationTrustResolver; +import org.springframework.security.authentication.AuthenticationTrustResolverImpl; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.util.Assert; + +/** + * Package private delegate class shared by the reactive and non-reactive resolver types. + * + * @author Rossen Stoyanchev + * @since 1.0.0 + */ +class ExceptionResolverDelegate { + + private AuthenticationTrustResolver resolver = new AuthenticationTrustResolverImpl(); + + + public void setAuthenticationTrustResolver(AuthenticationTrustResolver resolver) { + Assert.notNull(resolver, "AuthenticationTrustResolver is required"); + this.resolver = resolver; + } + + public GraphQLError resolveUnauthorized(DataFetchingEnvironment environment) { + return GraphqlErrorBuilder.newError(environment) + .errorType(ErrorType.UNAUTHORIZED) + .message("Unauthorized") + .build(); + } + + public GraphQLError resolveAccessDenied(DataFetchingEnvironment env, SecurityContext securityContext) { + return this.resolver.isAnonymous(securityContext.getAuthentication()) ? + resolveUnauthorized(env) : + GraphqlErrorBuilder.newError(env) + .errorType(ErrorType.FORBIDDEN) + .message("Forbidden") + .build(); + } + +} diff --git a/spring-graphql/src/main/java/org/springframework/graphql/security/ReactiveSecurityDataFetcherExceptionResolver.java b/spring-graphql/src/main/java/org/springframework/graphql/security/ReactiveSecurityDataFetcherExceptionResolver.java new file mode 100644 index 000000000..dd1db5ae9 --- /dev/null +++ b/spring-graphql/src/main/java/org/springframework/graphql/security/ReactiveSecurityDataFetcherExceptionResolver.java @@ -0,0 +1,77 @@ +/* + * Copyright 2002-2021 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.graphql.security; + +import java.util.Collections; +import java.util.List; + +import graphql.GraphQLError; +import graphql.schema.DataFetchingEnvironment; +import reactor.core.publisher.Mono; + +import org.springframework.graphql.execution.DataFetcherExceptionResolver; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.authentication.AuthenticationTrustResolver; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.core.context.ReactiveSecurityContextHolder; + +/** + * Reactive + * {@link org.springframework.graphql.execution.DataFetcherExceptionResolver} + * for Spring Security exceptions. For use in applications with a reactive + * transport (e.g. WebFlux HTTP endpoint). + * + * @author Rob Winch + * @author Rossen Stoyanchev + * @since 1.0.0 + */ +public class ReactiveSecurityDataFetcherExceptionResolver implements DataFetcherExceptionResolver { + + private final ExceptionResolverDelegate resolverDelegate = new ExceptionResolverDelegate(); + + + /** + * Set the resolver to use to check if an authentication is anonymous that + * in turn determines whether {@code AccessDeniedException} is classified + * as "unauthorized" or "forbidden". + * @param resolver the resolver to use + */ + public void setAuthenticationTrustResolver(AuthenticationTrustResolver resolver) { + this.resolverDelegate.setAuthenticationTrustResolver(resolver); + } + + + @Override + public Mono> resolveException(Throwable ex, DataFetchingEnvironment env) { + if (ex instanceof AuthenticationException) { + GraphQLError error = this.resolverDelegate.resolveUnauthorized(env); + return Mono.just(Collections.singletonList(error)); + } + if (ex instanceof AccessDeniedException) { + return ReactiveSecurityContextHolder.getContext() + .map(context -> { + GraphQLError error = this.resolverDelegate.resolveAccessDenied(env, context); + return Collections.singletonList(error); + }) + .switchIfEmpty(Mono.fromCallable(() -> { + GraphQLError error = this.resolverDelegate.resolveUnauthorized(env); + return Collections.singletonList(error); + })); + } + return Mono.empty(); + } + +} diff --git a/samples/webmvc-http-security/src/main/java/io/spring/sample/graphql/SecurityContextThreadLocalAccessor.java b/spring-graphql/src/main/java/org/springframework/graphql/security/SecurityContextThreadLocalAccessor.java similarity index 77% rename from samples/webmvc-http-security/src/main/java/io/spring/sample/graphql/SecurityContextThreadLocalAccessor.java rename to spring-graphql/src/main/java/org/springframework/graphql/security/SecurityContextThreadLocalAccessor.java index fd04aa2d2..66becbfb8 100644 --- a/samples/webmvc-http-security/src/main/java/io/spring/sample/graphql/SecurityContextThreadLocalAccessor.java +++ b/spring-graphql/src/main/java/org/springframework/graphql/security/SecurityContextThreadLocalAccessor.java @@ -1,13 +1,19 @@ -package io.spring.sample.graphql; +package org.springframework.graphql.security; + +import java.util.Map; import org.springframework.graphql.execution.ThreadLocalAccessor; import org.springframework.security.core.context.SecurityContext; import org.springframework.security.core.context.SecurityContextHolder; -import org.springframework.stereotype.Component; -import java.util.Map; - -@Component +/** + * {@link ThreadLocalAccessor} to extract and restore security context through + * {@link SecurityContextHolder}. + * + * @author Rob Winch + * @author Rossen Stoyanchev + * @since 1.0.0 + */ public class SecurityContextThreadLocalAccessor implements ThreadLocalAccessor { private static final String KEY = SecurityContext.class.getName(); @@ -28,4 +34,5 @@ public void restoreValues(Map values) { public void resetValues(Map values) { SecurityContextHolder.clearContext(); } + } diff --git a/spring-graphql/src/main/java/org/springframework/graphql/security/SecurityDataFetcherExceptionResolver.java b/spring-graphql/src/main/java/org/springframework/graphql/security/SecurityDataFetcherExceptionResolver.java new file mode 100644 index 000000000..7844199ac --- /dev/null +++ b/spring-graphql/src/main/java/org/springframework/graphql/security/SecurityDataFetcherExceptionResolver.java @@ -0,0 +1,70 @@ +/* + * Copyright 2002-2021 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.graphql.security; + +import graphql.GraphQLError; +import graphql.schema.DataFetchingEnvironment; + +import org.springframework.graphql.execution.DataFetcherExceptionResolverAdapter; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.authentication.AuthenticationTrustResolver; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.core.context.SecurityContextHolder; + +/** + * {@link org.springframework.graphql.execution.DataFetcherExceptionResolver} + * for Spring Security exceptions. For use in applications with a non-reactive + * transport (e.g. Spring MVC HTTP endpoint). + * + * @author Rob Winch + * @author Rossen Stoyanchev + * @since 1.0.0 + */ +public class SecurityDataFetcherExceptionResolver extends DataFetcherExceptionResolverAdapter { + + private final ExceptionResolverDelegate resolverDelegate = new ExceptionResolverDelegate(); + + + public SecurityDataFetcherExceptionResolver() { + setThreadLocalContextAware(true); + } + + + /** + * Set the resolver to use to check if an authentication is anonymous that + * in turn determines whether {@code AccessDeniedException} is classified + * as "unauthorized" or "forbidden". + * @param resolver the resolver to use + */ + public void setAuthenticationTrustResolver(AuthenticationTrustResolver resolver) { + this.resolverDelegate.setAuthenticationTrustResolver(resolver); + } + + + @Override + protected GraphQLError resolveToSingleError(Throwable ex, DataFetchingEnvironment env) { + if (ex instanceof AuthenticationException) { + return this.resolverDelegate.resolveUnauthorized(env); + } + if (ex instanceof AccessDeniedException) { + SecurityContext securityContext = SecurityContextHolder.getContext(); + return this.resolverDelegate.resolveAccessDenied(env, securityContext); + } + return null; + } + +} diff --git a/spring-graphql/src/main/java/org/springframework/graphql/security/package-info.java b/spring-graphql/src/main/java/org/springframework/graphql/security/package-info.java new file mode 100644 index 000000000..2a0fa51d8 --- /dev/null +++ b/spring-graphql/src/main/java/org/springframework/graphql/security/package-info.java @@ -0,0 +1,25 @@ +/* + * Copyright 2020-2021 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Spring Security support for GraphQL. + */ +@NonNullApi +@NonNullFields +package org.springframework.graphql.security; + +import org.springframework.lang.NonNullApi; +import org.springframework.lang.NonNullFields;