From 0c7598f7ea4a573d572add300f40dad34d4898d9 Mon Sep 17 00:00:00 2001 From: tobias-tengler <45513122+tobias-tengler@users.noreply.github.com> Date: Thu, 7 Nov 2024 19:20:12 +0100 Subject: [PATCH] Add ErrorOnAspNetCoreAuthorizationAttributes option --- .../Core/src/Abstractions/ErrorCodes.cs | 6 + .../AuthorizationTypeInterceptor.cs | 112 +++++++++++++++++- .../Properties/AuthCoreResources.Designer.cs | 12 ++ .../Properties/AuthCoreResources.resx | 6 + .../Core/src/Types/IReadOnlySchemaOptions.cs | 6 + .../Core/src/Types/SchemaOptions.cs | 2 + .../AnnotationBasedAuthorizationTests.cs | 96 +++++++++++++++ 7 files changed, 236 insertions(+), 4 deletions(-) diff --git a/src/HotChocolate/Core/src/Abstractions/ErrorCodes.cs b/src/HotChocolate/Core/src/Abstractions/ErrorCodes.cs index dad7fd68e68..0ebfa63540c 100644 --- a/src/HotChocolate/Core/src/Abstractions/ErrorCodes.cs +++ b/src/HotChocolate/Core/src/Abstractions/ErrorCodes.cs @@ -251,6 +251,12 @@ public static class Schema /// The specified directive argument does not exist. /// public const string UnknownDirectiveArgument = "HC0072"; + + /// + /// An underlying schema runtime type / member is annotated with a + /// Microsoft.AspNetCore.Authorization.* attribute that is not supported by Hot Chocolate. + /// + public const string UnsupportedAspNetCoreAuthorizationAttribute = "HC0081"; } public static class Scalars diff --git a/src/HotChocolate/Core/src/Authorization/AuthorizationTypeInterceptor.cs b/src/HotChocolate/Core/src/Authorization/AuthorizationTypeInterceptor.cs index fd4c4091eb0..4099678af84 100644 --- a/src/HotChocolate/Core/src/Authorization/AuthorizationTypeInterceptor.cs +++ b/src/HotChocolate/Core/src/Authorization/AuthorizationTypeInterceptor.cs @@ -1,3 +1,4 @@ +using System.Reflection; using System.Runtime.CompilerServices; using System.Runtime.InteropServices; using HotChocolate.Configuration; @@ -10,11 +11,19 @@ using HotChocolate.Utilities; using static HotChocolate.Authorization.AuthorizeDirectiveType.Names; using static HotChocolate.WellKnownContextData; +using static HotChocolate.Authorization.Properties.AuthCoreResources; namespace HotChocolate.Authorization; internal sealed partial class AuthorizationTypeInterceptor : TypeInterceptor { + private const string AspNetCoreAuthorizeAttributeName = "Microsoft.AspNetCore.Authorization.AuthorizeAttribute"; + private const string AspNetCoreAllowAnonymousAttributeName = + "Microsoft.AspNetCore.Authorization.AllowAnonymousAttribute"; + + private static readonly string _authorizeAttributeName = typeof(AuthorizeAttribute).FullName!; + private static readonly string _allowAnonymousAttributeName = typeof(AllowAnonymousAttribute).FullName!; + private readonly List _objectTypes = []; private readonly List _unionTypes = []; private readonly Dictionary _directives = new(); @@ -114,14 +123,79 @@ public override void OnBeforeCompleteType( ITypeCompletionContext completionContext, DefinitionBase definition) { + if (definition is not ObjectTypeDefinition typeDef) + { + return; + } + // last in the initialization we need to intercept the query type and ensure that // authorization configuration is applied to the special introspection and node fields. - if (ReferenceEquals(_queryContext, completionContext) && - definition is ObjectTypeDefinition typeDef) + if (ReferenceEquals(_queryContext, completionContext)) { var state = _state ?? throw ThrowHelper.StateNotInitialized(); HandleSpecialQueryFields(new ObjectTypeInfo(completionContext, typeDef), state); } + + if (_context.Options.ErrorOnAspNetCoreAuthorizationAttributes && !completionContext.IsIntrospectionType) + { + var runtimeType = typeDef.RuntimeType; + var attributesOnType = runtimeType.GetCustomAttributes().ToArray(); + + if (ContainsNamedAttribute(attributesOnType, AspNetCoreAuthorizeAttributeName)) + { + completionContext.ReportError( + UnsupportedAspNetCoreAttributeError( + AspNetCoreAuthorizeAttributeName, + _authorizeAttributeName, + runtimeType)); + return; + } + + if (ContainsNamedAttribute(attributesOnType, AspNetCoreAllowAnonymousAttributeName)) + { + completionContext.ReportError( + UnsupportedAspNetCoreAttributeError( + AspNetCoreAllowAnonymousAttributeName, + _allowAnonymousAttributeName, + runtimeType)); + return; + } + + foreach (var field in typeDef.Fields) + { + if (field.IsIntrospectionField) + { + continue; + } + + var fieldMember = field.ResolverMember ?? field.Member; + + if (fieldMember is not null) + { + var attributesOnResolver = fieldMember.GetCustomAttributes().ToArray(); + + if (ContainsNamedAttribute(attributesOnResolver, AspNetCoreAuthorizeAttributeName)) + { + completionContext.ReportError( + UnsupportedAspNetCoreAttributeError( + AspNetCoreAuthorizeAttributeName, + _authorizeAttributeName, + fieldMember)); + return; + } + + if (ContainsNamedAttribute(attributesOnResolver, AspNetCoreAllowAnonymousAttributeName)) + { + completionContext.ReportError( + UnsupportedAspNetCoreAttributeError( + AspNetCoreAllowAnonymousAttributeName, + _allowAnonymousAttributeName, + fieldMember)); + return; + } + } + } + } } public override void OnAfterCompleteTypes() @@ -179,7 +253,7 @@ private void InspectObjectTypesForAuthDirective(State state) // if the field contains the AnonymousAllowed flag we will not // apply authorization on it. - if(fieldDef.GetContextData().ContainsKey(AllowAnonymous)) + if (fieldDef.GetContextData().ContainsKey(AllowAnonymous)) { continue; } @@ -353,7 +427,7 @@ private void ApplyAuthMiddleware( { // if the field contains the AnonymousAllowed flag we will not apply authorization // on it. - if(fieldDef.GetContextData().ContainsKey(AllowAnonymous)) + if (fieldDef.GetContextData().ContainsKey(AllowAnonymous)) { return; } @@ -621,6 +695,36 @@ private State CreateState() return new State(options ?? new()); } + + private static bool ContainsNamedAttribute(Attribute[] attributes, string nameOfAttribute) + => attributes.Any(a => a.GetType().FullName == nameOfAttribute); + + private static ISchemaError UnsupportedAspNetCoreAttributeError( + string aspNetCoreAttributeName, + string properAttributeName, + Type runtimeType) + { + return SchemaErrorBuilder.New() + .SetMessage(string.Format(AuthorizationTypeInterceptor_UnsupportedAspNetCoreAttributeOnType, + aspNetCoreAttributeName, runtimeType.FullName, properAttributeName)) + .SetCode(ErrorCodes.Schema.UnsupportedAspNetCoreAuthorizationAttribute) + .Build(); + } + + private static ISchemaError UnsupportedAspNetCoreAttributeError( + string aspNetCoreAttributeName, + string properAttributeName, + MemberInfo member) + { + var nameOfDeclaringType = member.DeclaringType?.FullName; + var nameOfMember = member.Name; + + return SchemaErrorBuilder.New() + .SetMessage(string.Format(AuthorizationTypeInterceptor_UnsupportedAspNetCoreAttributeOnMember, + aspNetCoreAttributeName, nameOfDeclaringType, nameOfMember, properAttributeName)) + .SetCode(ErrorCodes.Schema.UnsupportedAspNetCoreAuthorizationAttribute) + .Build(); + } } static file class AuthorizationTypeInterceptorExtensions diff --git a/src/HotChocolate/Core/src/Authorization/Properties/AuthCoreResources.Designer.cs b/src/HotChocolate/Core/src/Authorization/Properties/AuthCoreResources.Designer.cs index 519551433f2..90610d65f1b 100644 --- a/src/HotChocolate/Core/src/Authorization/Properties/AuthCoreResources.Designer.cs +++ b/src/HotChocolate/Core/src/Authorization/Properties/AuthCoreResources.Designer.cs @@ -86,5 +86,17 @@ internal static string ThrowHelper_UnableToResolveTypeReg { return ResourceManager.GetString("ThrowHelper_UnableToResolveTypeReg", resourceCulture); } } + + internal static string AuthorizationTypeInterceptor_UnsupportedAspNetCoreAttributeOnType { + get { + return ResourceManager.GetString("AuthorizationTypeInterceptor_UnsupportedAspNetCoreAttributeOnType", resourceCulture); + } + } + + internal static string AuthorizationTypeInterceptor_UnsupportedAspNetCoreAttributeOnMember { + get { + return ResourceManager.GetString("AuthorizationTypeInterceptor_UnsupportedAspNetCoreAttributeOnMember", resourceCulture); + } + } } } diff --git a/src/HotChocolate/Core/src/Authorization/Properties/AuthCoreResources.resx b/src/HotChocolate/Core/src/Authorization/Properties/AuthCoreResources.resx index d6642c013c7..cae96320485 100644 --- a/src/HotChocolate/Core/src/Authorization/Properties/AuthCoreResources.resx +++ b/src/HotChocolate/Core/src/Authorization/Properties/AuthCoreResources.resx @@ -39,4 +39,10 @@ Unable to resolve a type registration. + + Found unsupported `{0}` on `{1}`. Use `{2}` instead. + + + Found unsupported `{0}` on `{1}.{2}`. Use `{3}` instead. + diff --git a/src/HotChocolate/Core/src/Types/IReadOnlySchemaOptions.cs b/src/HotChocolate/Core/src/Types/IReadOnlySchemaOptions.cs index c84f69b8999..423058e96d3 100644 --- a/src/HotChocolate/Core/src/Types/IReadOnlySchemaOptions.cs +++ b/src/HotChocolate/Core/src/Types/IReadOnlySchemaOptions.cs @@ -180,6 +180,12 @@ public interface IReadOnlySchemaOptions /// bool EnableTag { get; } + /// + /// Errors if either an ASP.NET Core [Authorize] or [AllowAnonymous] attribute + /// is used on a Hot Chocolate resolver or type definition. + /// + bool ErrorOnAspNetCoreAuthorizationAttributes { get; } + /// /// Specifies the default dependency injection scope for query fields. /// diff --git a/src/HotChocolate/Core/src/Types/SchemaOptions.cs b/src/HotChocolate/Core/src/Types/SchemaOptions.cs index 564cd82ebbb..d6dedbe97e6 100644 --- a/src/HotChocolate/Core/src/Types/SchemaOptions.cs +++ b/src/HotChocolate/Core/src/Types/SchemaOptions.cs @@ -210,6 +210,8 @@ public FieldBindingFlags DefaultFieldBindingFlags /// public bool EnableTag { get; set; } = true; + public bool ErrorOnAspNetCoreAuthorizationAttributes { get; set; } = true; + /// /// Defines the default dependency injection scope for query fields. /// diff --git a/src/HotChocolate/Core/test/Authorization.Tests/AnnotationBasedAuthorizationTests.cs b/src/HotChocolate/Core/test/Authorization.Tests/AnnotationBasedAuthorizationTests.cs index 938aea87016..8980ed3d16d 100644 --- a/src/HotChocolate/Core/test/Authorization.Tests/AnnotationBasedAuthorizationTests.cs +++ b/src/HotChocolate/Core/test/Authorization.Tests/AnnotationBasedAuthorizationTests.cs @@ -13,6 +13,78 @@ namespace HotChocolate.Authorization; public class AnnotationBasedAuthorizationTests { + [Fact] + public async Task Microsoft_AuthorizeAttribute_On_Method_Produces_Error() + { + var builder = new ServiceCollection() + .AddGraphQL() + .AddQueryType() + .AddAuthorizationCore(); + + var act = async () => await builder.BuildSchemaAsync(); + + var exception = await Assert.ThrowsAsync(act); + var error = exception.Errors.First(); + Assert.Equal(ErrorCodes.Schema.UnsupportedAspNetCoreAuthorizationAttribute, error.Code); + Assert.Equal( + "Found unsupported `Microsoft.AspNetCore.Authorization.AuthorizeAttribute` on `HotChocolate.Authorization.AnnotationBasedAuthorizationTests+QueryWithMicrosoftAuthorizeAttributeOnMethod.Field`. Use `HotChocolate.Authorization.AuthorizeAttribute` instead.", + error.Message); + } + + [Fact] + public async Task Microsoft_AllowAnonymousAttribute_On_Method_Produces_Error() + { + var builder = new ServiceCollection() + .AddGraphQL() + .AddQueryType() + .AddAuthorizationCore(); + + var act = async () => await builder.BuildSchemaAsync(); + + var exception = await Assert.ThrowsAsync(act); + var error = exception.Errors.First(); + Assert.Equal(ErrorCodes.Schema.UnsupportedAspNetCoreAuthorizationAttribute, error.Code); + Assert.Equal( + "Found unsupported `Microsoft.AspNetCore.Authorization.AllowAnonymousAttribute` on `HotChocolate.Authorization.AnnotationBasedAuthorizationTests+QueryWithMicrosoftAllowAnonymousAttributeOnMethod.Field`. Use `HotChocolate.Authorization.AllowAnonymousAttribute` instead.", + error.Message); + } + + [Fact] + public async Task Microsoft_AuthorizeAttribute_On_Type_Produces_Error() + { + var builder = new ServiceCollection() + .AddGraphQL() + .AddQueryType() + .AddAuthorizationCore(); + + var act = async () => await builder.BuildSchemaAsync(); + + var exception = await Assert.ThrowsAsync(act); + var error = exception.Errors.First(); + Assert.Equal(ErrorCodes.Schema.UnsupportedAspNetCoreAuthorizationAttribute, error.Code); + Assert.Equal( + "Found unsupported `Microsoft.AspNetCore.Authorization.AuthorizeAttribute` on `HotChocolate.Authorization.AnnotationBasedAuthorizationTests+QueryWithMicrosoftAuthorizeAttribute`. Use `HotChocolate.Authorization.AuthorizeAttribute` instead.", + error.Message); + } + + [Fact] + public async Task Microsoft_AllowAnonymousAttribute_On_Type_Produces_Error() + { + var builder = new ServiceCollection() + .AddGraphQL() + .AddQueryType() + .AddAuthorizationCore(); + + var act = async () => await builder.BuildSchemaAsync(); + + var exception = await Assert.ThrowsAsync(act); + var error = exception.Errors.First(); + Assert.Equal(ErrorCodes.Schema.UnsupportedAspNetCoreAuthorizationAttribute, error.Code); + Assert.Equal( + "Found unsupported `Microsoft.AspNetCore.Authorization.AllowAnonymousAttribute` on `HotChocolate.Authorization.AnnotationBasedAuthorizationTests+QueryWithMicrosoftAllowAnonymousAttribute`. Use `HotChocolate.Authorization.AllowAnonymousAttribute` instead.", + error.Message); + } + [Fact] public async Task Authorize_Person_NoAccess() { @@ -1138,4 +1210,28 @@ public ValueTask AuthorizeAsync( CancellationToken cancellationToken = default) => new(AuthorizeResult.NotAllowed); } + + public class QueryWithMicrosoftAuthorizeAttributeOnMethod + { + [Microsoft.AspNetCore.Authorization.Authorize] + public string Field() => "foo"; + } + + public class QueryWithMicrosoftAllowAnonymousAttributeOnMethod + { + [Microsoft.AspNetCore.Authorization.AllowAnonymous] + public string Field() => "foo"; + } + + [Microsoft.AspNetCore.Authorization.Authorize] + public class QueryWithMicrosoftAuthorizeAttribute + { + public string Field() => "foo"; + } + + [Microsoft.AspNetCore.Authorization.AllowAnonymous] + public class QueryWithMicrosoftAllowAnonymousAttribute + { + public string Field() => "foo"; + } }