Add security package with basic infrastructure

Closes gh-80, gh-82
This commit is contained in:
Rossen Stoyanchev
2021-07-05 21:00:43 +01:00
parent aea229778c
commit b594c19526
12 changed files with 281 additions and 146 deletions

View File

@@ -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 {

View File

@@ -17,6 +17,7 @@ package io.spring.sample.graphql;
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 class SecurityConfig {
return new MapReactiveUserDetailsService(rob, admin);
}
@Bean
public ReactiveSecurityDataFetcherExceptionResolver dataFetcherExceptionResolver() {
return new ReactiveSecurityDataFetcherExceptionResolver();
}
}

View File

@@ -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<List<GraphQLError>> 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<List<GraphQLError>> unauthorized(DataFetchingEnvironment environment) {
return Mono.fromCallable(() -> Collections.singletonList(
GraphqlErrorBuilder.newError(environment)
.errorType(ErrorType.UNAUTHORIZED)
.message("Unauthorized")
.build()));
}
private Mono<List<GraphQLError>> forbidden(DataFetchingEnvironment environment) {
return Mono.fromCallable(() -> Collections.singletonList(
GraphqlErrorBuilder.newError(environment)
.errorType(ErrorType.FORBIDDEN)
.message("Forbidden")
.build()));
}
}

View File

@@ -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));
};
}
}

View File

@@ -2,6 +2,9 @@ package io.spring.sample.graphql;
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 class SecurityConfig {
return new InMemoryUserDetailsManager(rob, admin);
}
@Bean
public SecurityDataFetcherExceptionResolver dataFetcherExceptionResolver() {
return new SecurityDataFetcherExceptionResolver();
}
@Bean
public ThreadLocalAccessor threadLocalAccessor() {
return new SecurityContextThreadLocalAccessor();
}
}

View File

@@ -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();
}
}

View File

@@ -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'

View File

@@ -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();
}
}

View File

@@ -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<List<GraphQLError>> 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();
}
}

View File

@@ -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 class SecurityContextThreadLocalAccessor implements ThreadLocalAccessor {
public void resetValues(Map<String, Object> values) {
SecurityContextHolder.clearContext();
}
}

View File

@@ -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;
}
}

View File

@@ -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;