diff --git a/src/SchemaFirstAuthorization/Example/Example.csproj b/src/SchemaFirstAuthorization/Example/Example.csproj new file mode 100644 index 0000000..910a43d --- /dev/null +++ b/src/SchemaFirstAuthorization/Example/Example.csproj @@ -0,0 +1,13 @@ + + + + net5.0 + + + + + + + + + diff --git a/src/SchemaFirstAuthorization/Example/Extensions.cs b/src/SchemaFirstAuthorization/Example/Extensions.cs new file mode 100644 index 0000000..0a095c8 --- /dev/null +++ b/src/SchemaFirstAuthorization/Example/Extensions.cs @@ -0,0 +1,22 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; + +namespace Example +{ + public static class Extensions + { + public static List WithAttribute(this Assembly assembly) + where T : Attribute + { + return assembly.GetTypes().Where(x => x.GetCustomAttributes().Length > 0).ToList(); + } + + public static T[] GetCustomAttributes(this Type type) + where T : Attribute + { + return type.GetCustomAttributes(typeof(T), true).OfType().ToArray(); + } + } +} \ No newline at end of file diff --git a/src/SchemaFirstAuthorization/Example/GraphQL.cs b/src/SchemaFirstAuthorization/Example/GraphQL.cs new file mode 100644 index 0000000..d3cd1cd --- /dev/null +++ b/src/SchemaFirstAuthorization/Example/GraphQL.cs @@ -0,0 +1,34 @@ +using System; +using System.Collections.Generic; +using System.Security.Claims; +using System.Text.Json.Serialization; +using Microsoft.AspNetCore.Http; + +namespace Example +{ + public class GraphQLUserContext : Dictionary + { + public ClaimsPrincipal User { get; set; } + } + + public class GraphQLRequest + { + [JsonPropertyName("operationName")] + public string OperationName { get; set; } + + [JsonPropertyName("query")] + public string Query { get; set; } + + [JsonPropertyName("variables")] + public GraphQL.Inputs Variables { get; set; } + } + + public class GraphQLSettings + { + public PathString Path { get; set; } = "/api/graphql"; + + public Func> BuildUserContext { get; set; } + + public bool EnableMetrics { get; set; } + } +} \ No newline at end of file diff --git a/src/SchemaFirstAuthorization/Example/GraphQLMiddleware.cs b/src/SchemaFirstAuthorization/Example/GraphQLMiddleware.cs new file mode 100644 index 0000000..c0ef01d --- /dev/null +++ b/src/SchemaFirstAuthorization/Example/GraphQLMiddleware.cs @@ -0,0 +1,86 @@ +using GraphQL; +using GraphQL.Authorization; +using GraphQL.SystemTextJson; +using GraphQL.Types; +using GraphQL.Validation; +using Microsoft.AspNetCore.Http; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Text.Json; +using System.Threading.Tasks; + +namespace Example +{ + public class GraphQLMiddleware + { + private readonly RequestDelegate _next; + private readonly GraphQLSettings _settings; + private readonly IDocumentExecuter _executer; + private readonly IDocumentWriter _writer; + private readonly JsonSerializerOptions _serializerOptions; + + public GraphQLMiddleware( + RequestDelegate next, + GraphQLSettings settings, + IDocumentExecuter executer, + IDocumentWriter writer) + { + _next = next; + _settings = settings; + _executer = executer; + _writer = writer; + + _serializerOptions = new JsonSerializerOptions(); + _serializerOptions.Converters.Add(new InputsConverter()); + } + + public async Task Invoke(HttpContext context, ISchema schema, IEnumerable rules) + { + if (!IsGraphQLRequest(context)) + { + await _next(context); + return; + } + + await ExecuteAsync(context, schema, rules); + } + + private bool IsGraphQLRequest(HttpContext context) + { + return context.Request.Path.StartsWithSegments(_settings.Path) + && string.Equals(context.Request.Method, "POST", StringComparison.OrdinalIgnoreCase); + } + + private async Task ExecuteAsync(HttpContext context, ISchema schema, IEnumerable rules) + { + var request = await JsonSerializer.DeserializeAsync(context.Request.BodyReader.AsStream(), _serializerOptions); + + var result = await _executer.ExecuteAsync(_ => + { + _.Schema = schema; + _.Query = request?.Query; + _.OperationName = request?.OperationName; + _.Inputs = request?.Variables; + _.UserContext = _settings.BuildUserContext?.Invoke(context); + _.ValidationRules = DocumentValidator.CoreRules.Concat(rules); + _.EnableMetrics = _settings.EnableMetrics; + _.UnhandledExceptionDelegate = context => + { + System.Diagnostics.Debug.WriteLine(context.OriginalException.Message); + }; + }); + + await WriteResponseAsync(context, result); + } + + private async Task WriteResponseAsync(HttpContext context, ExecutionResult result) + { + context.Response.ContentType = "application/json"; + context.Response.StatusCode = result.Errors?.Any() == true ? (int)HttpStatusCode.BadRequest : (int)HttpStatusCode.OK; + + await _writer.WriteAsync(context.Response.Body, result); + } + } +} \ No newline at end of file diff --git a/src/SchemaFirstAuthorization/Example/Program.cs b/src/SchemaFirstAuthorization/Example/Program.cs new file mode 100644 index 0000000..8bf8bc2 --- /dev/null +++ b/src/SchemaFirstAuthorization/Example/Program.cs @@ -0,0 +1,26 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace Example +{ + public class Program + { + public static void Main(string[] args) + { + CreateHostBuilder(args).Build().Run(); + } + + public static IHostBuilder CreateHostBuilder(string[] args) => + Host.CreateDefaultBuilder(args) + .ConfigureWebHostDefaults(webBuilder => + { + webBuilder.UseStartup(); + }); + } +} diff --git a/src/SchemaFirstAuthorization/Example/Properties/launchSettings.json b/src/SchemaFirstAuthorization/Example/Properties/launchSettings.json new file mode 100644 index 0000000..96b0ddc --- /dev/null +++ b/src/SchemaFirstAuthorization/Example/Properties/launchSettings.json @@ -0,0 +1,31 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:39369", + "sslPort": 44370 + } + }, + "profiles": { + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "launchUrl": "swagger", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "Example": { + "commandName": "Project", + "dotnetRunMessages": "true", + "launchBrowser": true, + "launchUrl": "swagger", + "applicationUrl": "https://localhost:5001;http://localhost:5000", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/src/SchemaFirstAuthorization/Example/Requirements.cs b/src/SchemaFirstAuthorization/Example/Requirements.cs new file mode 100644 index 0000000..c29927e --- /dev/null +++ b/src/SchemaFirstAuthorization/Example/Requirements.cs @@ -0,0 +1,33 @@ +using System.Threading.Tasks; +using GraphQL.Authorization; +using Microsoft.AspNetCore.Http; + +namespace Example +{ + /// + /// This represents a custom Authorization Requirement. These can be used + /// to get data from an external source (such as a database) or check + /// information that isn't directly available in the ClaimsPrincipal. + /// + /// For example, you can use IHttpContextAccessor to check data on the HttpContext. + /// + public class UserIsJoeRequirement : IAuthorizationRequirement + { + private readonly IHttpContextAccessor _accessor; + + public UserIsJoeRequirement(IHttpContextAccessor accessor) + { + _accessor = accessor; + } + + public Task Authorize(AuthorizationContext context) + { + // this is the same as context.User + if (_accessor.HttpContext.User.Identity?.Name != "Joe") + { + context.ReportError("User is not Joe!"); + } + return Task.CompletedTask; + } + } +} diff --git a/src/SchemaFirstAuthorization/Example/Resolvers.cs b/src/SchemaFirstAuthorization/Example/Resolvers.cs new file mode 100644 index 0000000..58530e7 --- /dev/null +++ b/src/SchemaFirstAuthorization/Example/Resolvers.cs @@ -0,0 +1,42 @@ +using System; +using GraphQL; + +namespace Example +{ + public class GraphTypeMetadataAttribute : Attribute + { + public GraphTypeMetadataAttribute(string typeDef) + { + TypeDef = typeDef; + } + + public string TypeDef { get; } + } + + [GraphTypeMetadata(@" + type User { + id: ID + name: String + } + ")] + public class User + { + public int Id { get; set; } + public string Name { get; set; } + } + + [GraphQLMetadata("Query")] + [GraphTypeMetadata(@" + extend type Query { + viewer: User + } + ")] + public class QueryType + { + [GraphQLAuthorize("CustomRequirement")] + public User Viewer() + { + return new User { Id = 1, Name = "Quinn" }; + } + } +} \ No newline at end of file diff --git a/src/SchemaFirstAuthorization/Example/Startup.cs b/src/SchemaFirstAuthorization/Example/Startup.cs new file mode 100644 index 0000000..d585f01 --- /dev/null +++ b/src/SchemaFirstAuthorization/Example/Startup.cs @@ -0,0 +1,93 @@ +using System; +using System.Linq; +using GraphQL; +using GraphQL.Authorization; +using GraphQL.Server.Ui.Playground; +using GraphQL.Types; +using GraphQL.Validation; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +namespace Example +{ + public class Startup + { + public Startup(IConfiguration configuration) + { + Configuration = configuration; + } + + public IConfiguration Configuration { get; } + + public void ConfigureServices(IServiceCollection services) + { + services.AddAuthorization(); + services.AddHttpContextAccessor(); + + services.AddSingleton(); + services.AddSingleton(); + + services.AddSingleton(); + services.AddTransient(); + services.AddTransient(); + + services.AddTransient(s => + { + var authSettings = new AuthorizationSettings(); + // add a policy that checks claims on the Authenticated User + authSettings.AddPolicy("AdminPolicy", p => p.RequireClaim("role", "Admin")); + + // add a policy which uses a custom AuthorizationRequirement + authSettings.AddPolicy("CustomRequirement", p => p.AddRequirement(s.GetRequiredService())); + return authSettings; + }); + + services.AddSingleton(s => + { + // get all types in assembly that have the GraphTypeMetadataAttribute attribute + // use the schema-first type definitions to build the schema + var types = typeof(QueryType).Assembly.WithAttribute(); + var typeDefs = types + .SelectMany(x => x.GetCustomAttributes()) + .Select(x => x.TypeDef) + .ToArray(); + + var schemaFirst = string.Join(Environment.NewLine, typeDefs); + + return Schema.For(schemaFirst, _ => + { + foreach (var type in types) + { + _.Types.Include(type); + } + }); + }); + } + + public void Configure(IApplicationBuilder app, IWebHostEnvironment env) + { + if (env.IsDevelopment()) + { + app.UseDeveloperExceptionPage(); + } + + app.UseRouting(); + app.UseAuthorization(); + + app.UseGraphQLPlayground(new PlaygroundOptions { GraphQLEndPoint = "/api/graphql" }); + + app.UseMiddleware(new GraphQLSettings + { + Path = "/api/graphql", + BuildUserContext = ctx => new GraphQLUserContext + { + User = ctx.User + }, + EnableMetrics = true + }); + } + } +} diff --git a/src/SchemaFirstAuthorization/Example/appsettings.Development.json b/src/SchemaFirstAuthorization/Example/appsettings.Development.json new file mode 100644 index 0000000..dba68eb --- /dev/null +++ b/src/SchemaFirstAuthorization/Example/appsettings.Development.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft": "Warning", + "Microsoft.Hosting.Lifetime": "Information" + } + } +} diff --git a/src/SchemaFirstAuthorization/Example/appsettings.json b/src/SchemaFirstAuthorization/Example/appsettings.json new file mode 100644 index 0000000..81ff877 --- /dev/null +++ b/src/SchemaFirstAuthorization/Example/appsettings.json @@ -0,0 +1,10 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft": "Warning", + "Microsoft.Hosting.Lifetime": "Information" + } + }, + "AllowedHosts": "*" +} diff --git a/src/SchemaFirstAuthorization/README.md b/src/SchemaFirstAuthorization/README.md new file mode 100644 index 0000000..f72ed49 --- /dev/null +++ b/src/SchemaFirstAuthorization/README.md @@ -0,0 +1,8 @@ +# ASP.NET Core Schema First GraphQL Example + +This example demonstrates a Schema First approach with authorizating access to graph types. + +``` +> ./run.sh +> browse to http://localhost:3000/ui/playground +``` diff --git a/src/SchemaFirstAuthorization/run.sh b/src/SchemaFirstAuthorization/run.sh new file mode 100755 index 0000000..efff7b1 --- /dev/null +++ b/src/SchemaFirstAuthorization/run.sh @@ -0,0 +1,5 @@ +#!/bin/bash + +cd Example +dotnet restore +dotnet run