2

I have a custom handler for security and an endpoint. It works as supposed to.

protected override Task HandleRequirementAsync(
    AuthorizationHandlerContext context,
    AwoRequirement requirement)
{
    HttpRequest request = Accessor.HttpContext.Request;
    string awo = request.Headers
        .SingleOrDefault(a => a.Key == "awo").Value.ToString();
    ...
    bools authorized = ...;

    if (authorized) context.Succeed(requirement);
    else context.Fail();
    return Task.CompletedTask;
}

[Authorize(Policy = "SomePolicy"), HttpPost("something")]
public async Task<IActionResult> Something([FromBody] Thing dto)
{ ... }

Now, I need to check the contents of the body, so I'm reading it in and analyze the contents. However, I noticed that with this addition, the endpoint isn't reached anymore. No exception or anything, just simply no hit, like if the route doesn't match. While debugging, I saw that the stream is used up so breakpointing the flow and reading again produces an empty string.

protected override Task HandleRequirementAsync( ... )
{
    HttpRequest request = Accessor.HttpContext.Request;
    ...
    using StreamReader stream = new StreamReader(Accessor.HttpContext.Request.Body);
    string body = stream.ReadToEndAsync().Result;
    Thing thing = JsonSerializer.Deserialize<Thing>(body);
    if (thing.IsBad())
      authorized &= fail;
    ...
    return Task.CompletedTask;
}

According to this answer I should rewind seeking zero point of the stream, this one suggests enabling of buffering too. (There's also suggestion here but there's no await in the sample, which is required on my system, so I couldn't try it out properly.) Based on that, I landed in the following.

protected override Task HandleRequirementAsync( ... )
{
    HttpRequest request = Accessor.HttpContext.Request;
    ...
    Accessor.HttpContext.Request.EnableBuffering();
    using StreamReader stream 
      = new StreamReader(Accessor.HttpContext.Request.Body);
    string body = stream.ReadToEndAsync().Result;
    Thing thing = JsonSerializer.Deserialize<Thing>(body);
    if (thing.IsBad())
      authorized &= fail;
    ...
    return Task.CompletedTask;
}

Now, going back and rerunning the code does read in from the stream again. However, the endpoint isn't found anymore still, just like before adding the above. It is reached if I remove the reading from the stream, though, so I sense that I'm still affecting the body reading somehow.

abdusco
  • 9,700
  • 2
  • 27
  • 44
Konrad Viltersten
  • 36,151
  • 76
  • 250
  • 438
  • I'm not sure, but have you tried leaving the body stream open and rewind it, by using the [StreamReader overload](https://learn.microsoft.com/en-us/dotnet/api/system.io.streamreader.-ctor?view=net-5.0#System_IO_StreamReader__ctor_System_IO_Stream_System_Text_Encoding_System_Boolean_System_Int32_System_Boolean_) that leaves the baseStream open and after using it to set `Accessor.HttpContext.Request.Body.Position = 0`? – Steeeve Aug 10 '21 at 08:40
  • Can you rewind the stream? I would be very surprised if this was possible, especially for large streams. It's not like the server would cache it anywhere, and HTTP doesn't allow for replaying message bodies. – Neil Aug 10 '21 at 08:41
  • According to the linked answers, it should be possible by using `Accessor.HttpContext.Request.EnableBuffering()` – Steeeve Aug 10 '21 at 08:43
  • @Neil It seems that I can, although only inside the handler. My stream read a body of, maybe, 300 bytes (not even 1 kb), so the size shouldn't be an issue. – Konrad Viltersten Aug 10 '21 at 09:00
  • You seem to be trying to access some sort of authorisation data in the body of 'every' message? This sounds very non-standard (for the reasons you are now coming across). Is this method the 'best' solution, or should you be looking at authentication in a different way? Headers (X-AUTH-XXX, or Authorization, etc etc) are 'easier'. – Neil Aug 10 '21 at 09:07
  • @Neil Thanks for the suggestions. This specific question considers the strange effect my reading from the stream has on the routing. It should work but for some strange reason , it doesn't. Let's hope someone who's got specific experience with this issue comes along. As for the suggestion itself, the concern is that the payload needs to be investigated and I don't control how it arrives. My work-around is to explicitly check it in the method receiving it. It works but I want to do it properly in the handler. Also, regardless, it's good to know why something supposed to work, doesn't. – Konrad Viltersten Aug 10 '21 at 10:49

2 Answers2

3

I'm guessing you need to check if the user is allowed to perform an action on the submitted resource (Thing) according to a policy.

The way to go about this is to implement an IAuthorizationHandler, which lets you pass & inspect the resource in question.

Assume we have a Post class:

interface IAuthored
{
    public string AuthorId { get; set; }
}

class Post : IAuthored
{
    public string Title { get; set; }
    public string AuthorId { get; set; }
}

We want to allow only the post author to make edits on it.

Here's the controller. I've added an [Authorize] to let only the authenticated users in.

public class PostController : ControllerBase
{
    private AppDbContext _dbContext;
    private IAuthorizationService _authorizationService;

    public PostController(IAuthorizationService authorizationService, AppDbContext dbContext)
    {
        _authorizationService = authorizationService;
        _dbContext = dbContext;
    }

    [Authorize] // this wouldn't work! [*]
    [HttpPatch("{id}")]
    public async Task<ActionResult> EditPost(string id)
    {
        var post = await _dbContext.Set<Post>().FindAsync(id);

        // oops! any authenticated user can edit this post.

        post.Title = "asd";
        await _dbContext.SaveChangesAsync();

        return Ok();
    }
}

Normally, with a simple policy, we'd annotate the action with [Authorize("my_policy")]. But it wouldn't work here, because [Authorize] attributes are evaluated (in authorization middleware) before the execution reaches the controller. ASP.NET Core (or you) cannot know which resource is being handled [*].

So we need to imperatively authorize the action. We define a requirement, and a policy that enforces it.

// just a marker class
class AuthorRequirement : IAuthorizationRequirement
{
}

services.AddAuthorization(
    options => {
        options.AddPolicy("editor", builder => builder.Requirements.Add(new AuthorRequirement()));
    }
);

Then implement a handler for this requirement. We can subclass AuthorizationHandler<TRequirement, TResource> or AuthorizationHandler<TRequirement>. I'm opting to authorize all classes that implement IAuthored interface.

class AuthorRequirementHandler : AuthorizationHandler<AuthorRequirement, IAuthored> // for a specific
{
    protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, AuthorRequirement requirement, IAuthored resource)
    {
        var userId = context.User.FindFirstValue(ClaimTypes.NameIdentifier);
        if (resource.AuthorId == userId)
        {
            context.Succeed(requirement);
        }

        // ... let other handlers take a stab at this
        return Task.CompletedTask;
    }
}

This works, but we're forced to handle authorization imperatively inside the action.

[*]: If we can infer the resource before it hits the endpoint, we can short-circuit the whole operation.

Let's create an extension method that lets us read the request body multiple times, and enable request buffering.

internal static class HttpRequestExtensions {
    public static async Task<T> ReadAsJsonAsync<T>(this HttpRequest request, JsonSerializerOptions options = null)
    {
        request.Body.Position = 0;
        var result = await request.ReadFromJsonAsync<T>(options);
        // reset the position again to let endpoint middleware read it
        request.Body.Position = 0;
        return result;
    }
}


app.Use((context, next) => {
    context.Request.EnableBuffering(1_000_000);
    return next();
});

app.UseAuthorization();

Now we can rewrite the handler to read the body and perform authorization "declaratively" [^1].

class AuthorRequirementHandler : AuthorizationHandler<AuthorRequirement>
{
    private readonly IHttpContextAccessor _httpContextAccessor;

    public AuthorRequirementHandler(IHttpContextAccessor httpContextAccessor)
    {
        _httpContextAccessor = httpContextAccessor;
    }

    protected override async Task HandleRequirementAsync(AuthorizationHandlerContext context, AuthorRequirement requirement)
    {
        var endpoint = _httpContextAccessor.HttpContext.GetEndpoint();
        var action  = endpoint.Metadata.OfType<ControllerActionDescriptor>().FirstOrDefault();
        // is the action is expecting a post DTO
        if (!action.Parameters.Any(p => p.ParameterType == typeof(Post)))
        {
            return;
        }
        
        var post = await _httpContextAccessor!.HttpContext!.Request.ReadAsJsonAsync<Post>();
        var userId = context.User.FindFirstValue(ClaimTypes.NameIdentifier);
        if (post.AuthorId == userId)
        {
            context.Succeed(requirement);
        }
    }
}

Once the user is authorized, the request follows the middleware chain and reaches the EndpointMiddleware, which reads & parses the request again, and delegates it to the controller action.


[^1]: I'd actually advise against this approach as it mixes authorization, which is a business requirement, with implementation details (HTTP) that it shouldn't know anything about. It's also not testable, unlike the previous approach.

abdusco
  • 9,700
  • 2
  • 27
  • 44
  • Once thing that confuses me is the implementation of `ReadAsJsonAsync(...)`. Perhaps I'm missing something in the flow but to me it looks like invoking it on an instance of `HttpContext` will call the method itself and, once there, it will recursively invoke itself again and again without an end. If it was an intentional recursive call, I fail to see the base case where it returns an actual value. What do I miss? – Konrad Viltersten Aug 11 '21 at 02:09
  • No no, I'm calling a different method, check again. It's confusing but I'm open for better naming suggestions :) – abdusco Aug 11 '21 at 05:11
  • Ahem... I know that it's not how the site is supposed to be used. With that disclaimer (and with assurance of inconvenient shamefulness), I wonder if you'd like to take a look at [this issue I have with logout](https://stackoverflow.com/questions/69326555/cant-remove-cookies-using-c-sharp/69344625?noredirect=1#comment122565314_69344625). I've worked out everything else (largely thanks to your contribution) but the last part is a no go. After days of research, I realize that I'm stuck. If you don't have time, I totally and utterly understand. I'm still immensely grateful. – Konrad Viltersten Sep 27 '21 at 10:23
0

You can check the code here. Either use the nuget package or just copy the method you need and if you wish to follow latest trends, rewrite it using Memory<byte>