Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add example for scopes-based authorization #1303

Merged
merged 1 commit into from
Sep 4, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ public override QueryExpression DefaultVisit(QueryExpression expression, TArgume
return null;
}

public override QueryExpression? VisitResourceFieldChain(ResourceFieldChainExpression expression, TArgument argument)
public override QueryExpression VisitResourceFieldChain(ResourceFieldChainExpression expression, TArgument argument)
{
return expression;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -180,7 +180,7 @@ private sealed class FilterWalker : QueryExpressionRewriter<object?>
{
public bool HasFilterOnArchivedAt { get; private set; }

public override QueryExpression? VisitResourceFieldChain(ResourceFieldChainExpression expression, object? argument)
public override QueryExpression VisitResourceFieldChain(ResourceFieldChainExpression expression, object? argument)
{
if (expression.Fields[0].Property.Name == nameof(TelevisionBroadcast.ArchivedAt))
{
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
using JetBrains.Annotations;
using JsonApiDotNetCore.Resources;
using JsonApiDotNetCore.Resources.Annotations;

namespace JsonApiDotNetCoreTests.IntegrationTests.Authorization.Scopes;

[UsedImplicitly(ImplicitUseTargetFlags.Members)]
[Resource(ControllerNamespace = "JsonApiDotNetCoreTests.IntegrationTests.Authorization.Scopes")]
public sealed class Actor : Identifiable<long>
{
[Attr]
public string Name { get; set; } = null!;

[Attr]
public DateTime BornAt { get; set; }

[HasMany]
public ISet<Movie> ActsIn { get; set; } = new HashSet<Movie>();
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
using System.Text;
using JsonApiDotNetCore.Configuration;
using JsonApiDotNetCore.Middleware;
using JsonApiDotNetCore.Resources;
using JsonApiDotNetCore.Resources.Annotations;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Primitives;

namespace JsonApiDotNetCoreTests.IntegrationTests.Authorization.Scopes;

internal sealed class AuthScopeSet
{
private const StringSplitOptions ScopesHeaderSplitOptions = StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries;

public const string ScopesHeaderName = "X-Auth-Scopes";

private readonly Dictionary<string, Permission> _scopes = new();

public static AuthScopeSet GetRequestedScopes(IHeaderDictionary requestHeaders)
{
var requestedScopes = new AuthScopeSet();

// In a real application, the scopes would be read from the signed ticket in the Authorization HTTP header.
// For simplicity, this sample allows the client to send them directly, which is obviously insecure.

if (requestHeaders.TryGetValue(ScopesHeaderName, out StringValues headerValue))
{
foreach (string scopeValue in headerValue.ToString().Split(' ', ScopesHeaderSplitOptions))
{
string[] scopeParts = scopeValue.Split(':', 2, ScopesHeaderSplitOptions);

if (scopeParts.Length == 2 && Enum.TryParse(scopeParts[0], true, out Permission permission) && Enum.IsDefined(permission))
{
requestedScopes.Include(scopeParts[1], permission);
}
}
}

return requestedScopes;
}

public void IncludeFrom(IJsonApiRequest request, ITargetedFields targetedFields)
{
Permission permission = request.IsReadOnly ? Permission.Read : Permission.Write;

if (request.PrimaryResourceType != null)
{
Include(request.PrimaryResourceType, permission);
}

if (request.SecondaryResourceType != null)
{
Include(request.SecondaryResourceType, permission);
}

if (request.Relationship != null)
{
Include(request.Relationship, permission);
}

foreach (RelationshipAttribute relationship in targetedFields.Relationships)
{
Include(relationship, permission);
}
}

public void Include(ResourceType resourceType, Permission permission)
{
Include(resourceType.PublicName, permission);
}

public void Include(RelationshipAttribute relationship, Permission permission)
{
Include(relationship.LeftType, permission);
Include(relationship.RightType, permission);
}

private void Include(string name, Permission permission)
{
// Unify with existing entries. For example, adding read:movies when write:movies already exists is a no-op.

if (_scopes.TryGetValue(name, out Permission value))
{
if (value >= permission)
{
return;
}
}

_scopes[name] = permission;
}

public bool ContainsAll(AuthScopeSet other)
{
foreach (string otherName in other._scopes.Keys)
{
if (!_scopes.TryGetValue(otherName, out Permission thisPermission))
{
return false;
}

if (thisPermission < other._scopes[otherName])
{
return false;
}
}

return true;
}

public override string ToString()
{
var builder = new StringBuilder();

foreach ((string name, Permission permission) in _scopes.OrderBy(scope => scope.Key))
{
if (builder.Length > 0)
{
builder.Append(' ');
}

builder.Append($"{permission.ToString().ToLowerInvariant()}:{name}");
}

return builder.ToString();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
using JetBrains.Annotations;
using JsonApiDotNetCore.Resources;
using JsonApiDotNetCore.Resources.Annotations;

namespace JsonApiDotNetCoreTests.IntegrationTests.Authorization.Scopes;

[UsedImplicitly(ImplicitUseTargetFlags.Members)]
[Resource(ControllerNamespace = "JsonApiDotNetCoreTests.IntegrationTests.Authorization.Scopes")]
public sealed class Genre : Identifiable<long>
{
[Attr]
public string Name { get; set; } = null!;

[HasMany]
public ISet<Movie> Movies { get; set; } = new HashSet<Movie>();
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
using JetBrains.Annotations;
using JsonApiDotNetCore.Resources;
using JsonApiDotNetCore.Resources.Annotations;

namespace JsonApiDotNetCoreTests.IntegrationTests.Authorization.Scopes;

[UsedImplicitly(ImplicitUseTargetFlags.Members)]
[Resource(ControllerNamespace = "JsonApiDotNetCoreTests.IntegrationTests.Authorization.Scopes")]
public sealed class Movie : Identifiable<long>
{
[Attr]
public string Title { get; set; } = null!;

[Attr]
public int ReleaseYear { get; set; }

[Attr]
public int DurationInSeconds { get; set; }

[HasOne]
public Genre Genre { get; set; } = null!;

[HasMany]
public ISet<Actor> Cast { get; set; } = new HashSet<Actor>();
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
using System.Net;
using JsonApiDotNetCore.AtomicOperations;
using JsonApiDotNetCore.Configuration;
using JsonApiDotNetCore.Controllers;
using JsonApiDotNetCore.Middleware;
using JsonApiDotNetCore.Resources;
using JsonApiDotNetCore.Serialization.Objects;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;

namespace JsonApiDotNetCoreTests.IntegrationTests.Authorization.Scopes;

public sealed class OperationsController : JsonApiOperationsController
{
public OperationsController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, IOperationsProcessor processor,
IJsonApiRequest request, ITargetedFields targetedFields)
: base(options, resourceGraph, loggerFactory, processor, request, targetedFields)
{
}

public override async Task<IActionResult> PostOperationsAsync(IList<OperationContainer> operations, CancellationToken cancellationToken)
{
AuthScopeSet requestedScopes = AuthScopeSet.GetRequestedScopes(HttpContext.Request.Headers);
AuthScopeSet requiredScopes = GetRequiredScopes(operations);

if (!requestedScopes.ContainsAll(requiredScopes))
{
return Error(new ErrorObject(HttpStatusCode.Unauthorized)
{
Title = "Insufficient permissions to perform this request.",
Detail = $"Performing this request requires the following scopes: {requiredScopes}.",
Source = new ErrorSource
{
Header = AuthScopeSet.ScopesHeaderName
}
});
}

return await base.PostOperationsAsync(operations, cancellationToken);
}

private AuthScopeSet GetRequiredScopes(IEnumerable<OperationContainer> operations)
{
var requiredScopes = new AuthScopeSet();

foreach (OperationContainer operation in operations)
{
requiredScopes.IncludeFrom(operation.Request, operation.TargetedFields);
}

return requiredScopes;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
namespace JsonApiDotNetCoreTests.IntegrationTests.Authorization.Scopes;

internal enum Permission
{
Read,

// Write access implicitly includes read access, because POST/PATCH in JSON:API may return the changed resource.
Write
}