diff --git a/GraphQL.Server.sln b/GraphQL.Server.sln index fc5b78d8..086ee8e6 100644 --- a/GraphQL.Server.sln +++ b/GraphQL.Server.sln @@ -37,7 +37,11 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Transports.Subscriptions.Ab EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Ui.Voyager", "src\Ui.Voyager\Ui.Voyager.csproj", "{B2C278E4-6A1A-4F83-AE53-C9469B4056EE}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Core", "src\Core\Core.csproj", "{4872A0F3-FA1B-410B-834C-8A5653621E56}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Core", "src\Core\Core.csproj", "{4872A0F3-FA1B-410B-834C-8A5653621E56}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Authorization.AspNetCore", "src\Authorization.AspNetCore\Authorization.AspNetCore.csproj", "{7A71AF0D-FE5F-4607-A6F6-960FD98CF840}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Authorization.AspNetCore.Tests", "tests\Authorization.AspNetCore.Tests\Authorization.AspNetCore.Tests.csproj", "{741DEEE6-FD0B-4F99-8A6F-43584B3E8D5F}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -89,6 +93,14 @@ Global {4872A0F3-FA1B-410B-834C-8A5653621E56}.Debug|Any CPU.Build.0 = Debug|Any CPU {4872A0F3-FA1B-410B-834C-8A5653621E56}.Release|Any CPU.ActiveCfg = Release|Any CPU {4872A0F3-FA1B-410B-834C-8A5653621E56}.Release|Any CPU.Build.0 = Release|Any CPU + {7A71AF0D-FE5F-4607-A6F6-960FD98CF840}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {7A71AF0D-FE5F-4607-A6F6-960FD98CF840}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7A71AF0D-FE5F-4607-A6F6-960FD98CF840}.Release|Any CPU.ActiveCfg = Release|Any CPU + {7A71AF0D-FE5F-4607-A6F6-960FD98CF840}.Release|Any CPU.Build.0 = Release|Any CPU + {741DEEE6-FD0B-4F99-8A6F-43584B3E8D5F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {741DEEE6-FD0B-4F99-8A6F-43584B3E8D5F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {741DEEE6-FD0B-4F99-8A6F-43584B3E8D5F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {741DEEE6-FD0B-4F99-8A6F-43584B3E8D5F}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/src/Authorization.AspNetCore/Authorization.AspNetCore.csproj b/src/Authorization.AspNetCore/Authorization.AspNetCore.csproj new file mode 100644 index 00000000..dd1421c0 --- /dev/null +++ b/src/Authorization.AspNetCore/Authorization.AspNetCore.csproj @@ -0,0 +1,28 @@ + + + + netstandard2.0 + GraphQL.Server.Authorization.AspNetCore + GraphQL.Server.Authorization.AspNetCore + graphql-dotnet server + graphql-dotnet + Pekka Heikura + HTTP authorization middleware for graphql + https://github.com/graphql-dotnet/server + https://github.com/graphql-dotnet/server + Git + GraphQL authentication authorization middleware + Pekka Heikura + + + + + + + + + + + + + diff --git a/src/Authorization.AspNetCore/AuthorizationMetadataExtensions.cs b/src/Authorization.AspNetCore/AuthorizationMetadataExtensions.cs new file mode 100644 index 00000000..8e73a738 --- /dev/null +++ b/src/Authorization.AspNetCore/AuthorizationMetadataExtensions.cs @@ -0,0 +1,34 @@ +using System.Collections.Generic; +using GraphQL.Builders; +using GraphQL.Types; + +namespace GraphQL.Server.Authorization.AspNetCore +{ + public static class AuthorizationMetadataExtensions + { + public const string PolicyKey = "Authorization__Policies"; + + public static bool RequiresAuthorization(this IProvideMetadata type) + { + var policies = GetPolicies(type); + return policies != null && policies.Count > 0; + } + + public static void AuthorizeWith(this IProvideMetadata type, string policy) + { + var list = GetPolicies(type) ?? new List(); + list.Fill(policy); + type.Metadata[PolicyKey] = list; + } + + public static FieldBuilder AuthorizeWith( + this FieldBuilder builder, string policy) + { + builder.FieldType.AuthorizeWith(policy); + return builder; + } + + public static List GetPolicies(this IProvideMetadata type) => + type.GetMetadata>(PolicyKey, null); + } +} \ No newline at end of file diff --git a/src/Authorization.AspNetCore/AuthorizationValidationRule.cs b/src/Authorization.AspNetCore/AuthorizationValidationRule.cs new file mode 100644 index 00000000..67fb78de --- /dev/null +++ b/src/Authorization.AspNetCore/AuthorizationValidationRule.cs @@ -0,0 +1,173 @@ +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using GraphQL.Language.AST; +using GraphQL.Types; +using GraphQL.Validation; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Authorization.Infrastructure; +using Microsoft.AspNetCore.Http; + +namespace GraphQL.Server.Authorization.AspNetCore +{ + public class AuthorizationValidationRule : IValidationRule + { + private readonly IAuthorizationService _authorizationService; + private readonly IHttpContextAccessor _httpContextAccessor; + + public AuthorizationValidationRule( + IAuthorizationService authorizationService, + IHttpContextAccessor httpContextAccessor) + { + _authorizationService = authorizationService; + _httpContextAccessor = httpContextAccessor; + } + + public INodeVisitor Validate(ValidationContext context) + { + return new EnterLeaveListener(_ => + { + var operationType = OperationType.Query; + + // this could leak info about hidden fields or types in error messages + // it would be better to implement a filter on the Schema so it + // acts as if they just don't exist vs. an auth denied error + // - filtering the Schema is not currently supported + + _.Match(astType => + { + operationType = astType.OperationType; + + var type = context.TypeInfo.GetLastType(); + AuthorizeAsync(astType, type, context, operationType).GetAwaiter().GetResult(); + }); + + _.Match(objectFieldAst => + { + var argumentType = context.TypeInfo.GetArgument().ResolvedType.GetNamedType() as IComplexGraphType; + if (argumentType == null) + { + return; + } + + var fieldType = argumentType.GetField(objectFieldAst.Name); + AuthorizeAsync(objectFieldAst, fieldType, context, operationType).GetAwaiter().GetResult(); + }); + + _.Match(fieldAst => + { + var fieldDef = context.TypeInfo.GetFieldDef(); + if (fieldDef == null) + { + return; + } + + // check target field + AuthorizeAsync(fieldAst, fieldDef, context, operationType).GetAwaiter().GetResult(); + // check returned graph type + AuthorizeAsync(fieldAst, fieldDef.ResolvedType, context, operationType).GetAwaiter().GetResult(); + }); + }); + } + + private async Task AuthorizeAsync( + INode node, + IProvideMetadata type, + ValidationContext context, + OperationType operationType) + { + if (type == null || !type.RequiresAuthorization()) + { + return; + } + + var policyNames = type.GetPolicies(); + if (policyNames.Count == 0) + { + return; + } + + var tasks = new List>(policyNames.Count); + foreach (var policyName in policyNames) + { + var task = _authorizationService.AuthorizeAsync(this._httpContextAccessor.HttpContext.User, policyName); + tasks.Add(task); + } + await Task.WhenAll(tasks); + + foreach (var task in tasks) + { + var result = task.Result; + if (!result.Succeeded) + { + var stringBuilder = new StringBuilder("You are not authorized to run this "); + stringBuilder.Append(operationType.ToString().ToLower()); + stringBuilder.AppendLine("."); + + foreach (var failure in result.Failure.FailedRequirements) + { + AppendFailureLine(stringBuilder, failure); + } + + context.ReportError( + new ValidationError(context.OriginalQuery, "authorization", stringBuilder.ToString(), node)); + } + } + } + + private static void AppendFailureLine( + StringBuilder stringBuilder, + IAuthorizationRequirement authorizationRequirement) + { + switch (authorizationRequirement) + { + case ClaimsAuthorizationRequirement claimsAuthorizationRequirement: + stringBuilder.Append("Required claim '"); + stringBuilder.Append(claimsAuthorizationRequirement.ClaimType); + if (claimsAuthorizationRequirement.AllowedValues == null || !claimsAuthorizationRequirement.AllowedValues.Any()) + { + stringBuilder.AppendLine("' is not present."); + } + else + { + stringBuilder.Append("' with any value of '"); + stringBuilder.Append(string.Join(", ", claimsAuthorizationRequirement.AllowedValues)); + stringBuilder.AppendLine("' is not present."); + } + break; + case DenyAnonymousAuthorizationRequirement denyAnonymousAuthorizationRequirement: + stringBuilder.AppendLine("The current user must be authenticated."); + break; + case NameAuthorizationRequirement nameAuthorizationRequirement: + stringBuilder.Append("The current user name must match the name '"); + stringBuilder.Append(nameAuthorizationRequirement.RequiredName); + stringBuilder.AppendLine("'."); + break; + case OperationAuthorizationRequirement operationAuthorizationRequirement: + stringBuilder.Append("Required operation '"); + stringBuilder.Append(operationAuthorizationRequirement.Name); + stringBuilder.AppendLine("' was not present."); + break; + case RolesAuthorizationRequirement rolesAuthorizationRequirement: + if (rolesAuthorizationRequirement.AllowedRoles == null || !rolesAuthorizationRequirement.AllowedRoles.Any()) + { + // This should never happen. + stringBuilder.AppendLine("Required roles are not present."); + } + else + { + stringBuilder.Append("Required roles '"); + stringBuilder.Append(string.Join(", ", rolesAuthorizationRequirement.AllowedRoles)); + stringBuilder.AppendLine("' are not present."); + } + break; + default: + stringBuilder.Append("Requirement '"); + stringBuilder.Append(authorizationRequirement.GetType().Name); + stringBuilder.AppendLine("' was not satisfied."); + break; + } + } + } +} \ No newline at end of file diff --git a/src/Authorization.AspNetCore/GraphQLAuthorizeAttribute.cs b/src/Authorization.AspNetCore/GraphQLAuthorizeAttribute.cs new file mode 100644 index 00000000..b28d6510 --- /dev/null +++ b/src/Authorization.AspNetCore/GraphQLAuthorizeAttribute.cs @@ -0,0 +1,19 @@ +using GraphQL.Utilities; + +namespace GraphQL.Server.Authorization.AspNetCore +{ + public class GraphQLAuthorizeAttribute : GraphQLAttribute + { + public string Policy { get; set; } + + public override void Modify(TypeConfig type) + { + type.AuthorizeWith(Policy); + } + + public override void Modify(FieldConfig field) + { + field.AuthorizeWith(Policy); + } + } +} \ No newline at end of file diff --git a/src/Authorization.AspNetCore/GraphQlBuilderExtensions.cs b/src/Authorization.AspNetCore/GraphQlBuilderExtensions.cs new file mode 100644 index 00000000..2123ace4 --- /dev/null +++ b/src/Authorization.AspNetCore/GraphQlBuilderExtensions.cs @@ -0,0 +1,44 @@ +using System; +using GraphQL.Server.Authorization.AspNetCore; +using GraphQL.Validation; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace GraphQL.Server +{ + public static class GraphQLBuilderExtensions + { + /// + /// Adds the GraphQL authorization. + /// + /// The GraphQL builder. + /// + public static IGraphQLBuilder AddGraphQLAuthorization(this IGraphQLBuilder builder) + { + builder.Services.TryAddSingleton(); + builder + .Services + .AddTransient() + .AddAuthorization(); + return builder; + } + + /// + /// Adds the GraphQL authorization. + /// + /// The GraphQL builder. + /// An action delegate to configure the provided . + /// The GraphQL builder. + public static IGraphQLBuilder AddGraphQLAuthorization(this IGraphQLBuilder builder, Action options) + { + builder.Services.TryAddSingleton(); + builder + .Services + .AddTransient() + .AddAuthorization(options); + return builder; + } + } +} diff --git a/tests/Authorization.AspNetCore.Tests/Authorization.AspNetCore.Tests.csproj b/tests/Authorization.AspNetCore.Tests/Authorization.AspNetCore.Tests.csproj new file mode 100644 index 00000000..b8382bba --- /dev/null +++ b/tests/Authorization.AspNetCore.Tests/Authorization.AspNetCore.Tests.csproj @@ -0,0 +1,31 @@ + + + + netcoreapp2.0 + false + + + + + + + + + + + + + + + + + + + GraphQL.Server.Authorization.AspNetCore.Tests + + + + + + + diff --git a/tests/Authorization.AspNetCore.Tests/AuthorizationValidationRuleTests.cs b/tests/Authorization.AspNetCore.Tests/AuthorizationValidationRuleTests.cs new file mode 100644 index 00000000..8847a1ab --- /dev/null +++ b/tests/Authorization.AspNetCore.Tests/AuthorizationValidationRuleTests.cs @@ -0,0 +1,245 @@ +using System.Collections.Generic; +using GraphQL.Types; +using Xunit; + +namespace GraphQL.Server.Authorization.AspNetCore.Tests +{ + public class AuthorizationValidationRuleTests : ValidationTestBase + { + [Fact] + public void class_policy_success() + { + ConfigureAuthorizationOptions( + options => + { + options.AddPolicy("ClassPolicy", x => x.RequireClaim("admin")); + }); + + ShouldPassRule(_ => + { + _.Query = @"query { post }"; + _.Schema = BasicSchema(); + _.User = CreatePrincipal(claims: new Dictionary + { + {"Admin", "true"} + }); + }); + } + + [Fact] + public void class_policy_fail() + { + ConfigureAuthorizationOptions( + options => + { + options.AddPolicy("ClassPolicy", x => x.RequireClaim("admin")); + }); + + ShouldFailRule(_ => + { + _.Query = @"query { post }"; + _.Schema = BasicSchema(); + }); + } + + [Fact] + public void field_policy_success() + { + ConfigureAuthorizationOptions( + options => + { + options.AddPolicy("FieldPolicy", x => x.RequireClaim("admin")); + }); + + ShouldPassRule(_ => + { + _.Query = @"query { post }"; + _.Schema = BasicSchema(); + _.User = CreatePrincipal(claims: new Dictionary + { + {"Admin", "true"} + }); + }); + } + + [Fact] + public void field_policy_fail() + { + ConfigureAuthorizationOptions( + options => + { + options.AddPolicy("FieldPolicy", x => x.RequireClaim("admin")); + }); + + ShouldFailRule(_ => + { + _.Query = @"query { post }"; + _.Schema = BasicSchema(); + }); + } + + [Fact] + public void nested_type_policy_success() + { + ConfigureAuthorizationOptions( + options => + { + options.AddPolicy("PostPolicy", x => x.RequireClaim("admin")); + }); + + ShouldPassRule(_ => + { + _.Query = @"query { post }"; + _.Schema = NestedSchema(); + _.User = CreatePrincipal(claims: new Dictionary + { + {"Admin", "true"} + }); + }); + } + + [Fact] + public void nested_type_policy_fail() + { + ConfigureAuthorizationOptions( + options => + { + options.AddPolicy("PostPolicy", x => x.RequireClaim("admin")); + }); + + ShouldFailRule(_ => + { + _.Query = @"query { post }"; + _.Schema = NestedSchema(); + }); + } + + [Fact] + public void passes_with_claim_on_input_type() + { + ConfigureAuthorizationOptions( + options => + { + options.AddPolicy("FieldPolicy", x => x.RequireClaim("admin")); + }); + + ShouldPassRule(_ => + { + _.Query = @"query { author(input: { name: ""Quinn"" }) }"; + _.Schema = TypedSchema(); + _.User = CreatePrincipal(claims: new Dictionary + { + {"Admin", "true"} + }); + }); + } + + [Fact] + public void fails_on_missing_claim_on_input_type() + { + ConfigureAuthorizationOptions( + options => + { + options.AddPolicy("FieldPolicy", x => x.RequireClaim("admin")); + }); + + ShouldFailRule(_ => + { + _.Query = @"query { author(input: { name: ""Quinn"" }) }"; + _.Schema = TypedSchema(); + }); + } + + private ISchema BasicSchema() + { + string defs = @" + type Query { + post(id: ID!): String + } + "; + + return Schema.For(defs, _ => + { + _.Types.Include(); + }); + } + + [GraphQLMetadata("Query")] + [GraphQLAuthorize(Policy = "ClassPolicy")] + public class BasicQueryWithAttributesAndClassPolicy + { + public string Post(string id) + { + return ""; + } + } + + [GraphQLMetadata("Query")] + public class BasicQueryWithAttributesAndFieldPolicy + { + [GraphQLAuthorize(Policy = "FieldPolicy")] + public string Post(string id) + { + return ""; + } + } + + private ISchema NestedSchema() + { + string defs = @" + type Query { + post(id: ID!): Post + } + + type Post { + id: ID! + } + "; + + return Schema.For(defs, _ => + { + _.Types.Include(); + _.Types.Include(); + }); + } + + [GraphQLMetadata("Query")] + public class NestedQueryWithAttributes + { + public Post Post(string id) + { + return null; + } + } + + [GraphQLAuthorize(Policy = "PostPolicy")] + public class Post + { + public string Id { get; set; } + } + + public class Author + { + public string Name { get; set; } + } + + private ISchema TypedSchema() + { + var query = new ObjectGraphType(); + query.Field( + "author", + arguments: new QueryArguments(new QueryArgument { Name = "input" }), + resolve: context => "testing" + ); + return new Schema { Query = query }; + } + + public class AuthorInputType : InputObjectGraphType + { + public AuthorInputType() + { + Field(x => x.Name).AuthorizeWith("FieldPolicy"); + } + } + } +} diff --git a/tests/Authorization.AspNetCore.Tests/ValidationTestBase.cs b/tests/Authorization.AspNetCore.Tests/ValidationTestBase.cs new file mode 100644 index 00000000..ad3c03d6 --- /dev/null +++ b/tests/Authorization.AspNetCore.Tests/ValidationTestBase.cs @@ -0,0 +1,122 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Security.Claims; +using GraphQL.Execution; +using GraphQL.Http; +using GraphQL.Types; +using GraphQL.Validation; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Shouldly; + +namespace GraphQL.Server.Authorization.AspNetCore.Tests +{ + public class ValidationTestConfig + { + private readonly List _rules = new List(); + + public string Query { get; set; } + public ISchema Schema { get; set; } + public IEnumerable Rules => _rules; + public ClaimsPrincipal User { get; set; } + public Inputs Inputs { get; set; } + + public void Rule(params IValidationRule[] rules) + { + _rules.AddRange(rules); + } + } + public class GraphQLUserContext + { + public ClaimsPrincipal User { get; set; } + } + public class ValidationTestBase + { + private IDocumentExecuter _executor = new DocumentExecuter(); + private IDocumentWriter _writer = new DocumentWriter(indent: true); + + protected HttpContext HttpContext { get; private set; } + + protected AuthorizationValidationRule Rule { get; private set; } + + protected void ConfigureAuthorizationOptions(Action setupOptions) + { + var (authorizationService, httpContextAccessor) = BuildServices(setupOptions); + HttpContext = httpContextAccessor.HttpContext; + Rule = new AuthorizationValidationRule(authorizationService, httpContextAccessor); + } + + protected void ShouldPassRule(Action configure) + { + var config = new ValidationTestConfig(); + config.Rule(Rule); + configure(config); + + config.Rules.Any().ShouldBeTrue("Must provide at least one rule to validate against."); + + config.Schema.Initialize(); + + var result = Validate(config); + + var message = ""; + if (result.Errors?.Any() == true) + { + message = string.Join(", ", result.Errors.Select(x => x.Message)); + } + result.IsValid.ShouldBeTrue(message); + } + + protected void ShouldFailRule(Action configure) + { + var config = new ValidationTestConfig(); + config.Rule(Rule); + configure(config); + + config.Rules.Any().ShouldBeTrue("Must provide at least one rule to validate against."); + + config.Schema.Initialize(); + + var result = Validate(config); + + result.IsValid.ShouldBeFalse("Expected validation errors though there were none."); + } + + private (IAuthorizationService, IHttpContextAccessor) BuildServices(Action setupOptions) + { + var services = new ServiceCollection(); + services.AddAuthorization(setupOptions); + services.AddLogging(); + services.AddOptions(); + services.AddSingleton(); + var serviceProvider = services.BuildServiceProvider(); + var authorizationService = serviceProvider.GetRequiredService(); + var httpContextAccessor = serviceProvider.GetRequiredService(); + httpContextAccessor.HttpContext = new DefaultHttpContext(); + return (authorizationService, httpContextAccessor); + } + + private IValidationResult Validate(ValidationTestConfig config) + { + this.HttpContext.User = config.User; + var userContext = new GraphQLUserContext { User = config.User }; + var documentBuilder = new GraphQLDocumentBuilder(); + var document = documentBuilder.Build(config.Query); + var validator = new DocumentValidator(); + return validator.Validate(config.Query, config.Schema, document, config.Rules, userContext, config.Inputs); + } + + protected ClaimsPrincipal CreatePrincipal(string authenticationType = null, IDictionary claims = null) + { + var claimsList = new List(); + + claims?.Apply(c => + { + claimsList.Add(new Claim(c.Key, c.Value)); + }); + + return new ClaimsPrincipal(new ClaimsIdentity(claimsList, authenticationType)); + } + } +}