I have domain service that throws a custom DomainServiceValidationException for a business validation. I want to globally capture the exception and return in ModelState. My current working solution is using ExceptionFilterAttribute
public class ExceptionHandlerAttribute : ExceptionFilterAttribute
{
private readonly ILogger<ExceptionHandlerAttribute> _logger;
public ExceptionHandlerAttribute(ILogger<ExceptionHandlerAttribute> logger)
{
_logger = logger;
}
public override void OnException(ExceptionContext context)
{
if (context == null || context.ExceptionHandled)
{
return;
}
if(context.Exception is DomainServiceValidationException)
{
context.ModelState.AddModelError("Errors", context.Exception.Message);
context.HttpContext.Response.StatusCode = (int)HttpStatusCode.BadRequest;
context.Result = new BadRequestObjectResult(context.ModelState);
}
else
{
handle exception
}
}
}
I want to know if there is any way to access ModelState in UseExceptionHandler middleware
public void Configure(IApplicationBuilder app, IHostingEnvironment env, IApplicationLifetime appLifetime)
{
app.UseExceptionHandler(new ExceptionHandlerOptions()
{
ExceptionHandler = async (context) =>
{
var ex = context.Features.Get<IExceptionHandlerFeature>().Error;
// how to access to ModelState here
}
});
}
}
Shorten answer:
No, you cannot access ModelState in UseExceptionHandler middleware.
Explanation:
Firstly you need to know ModelState is only available after Model Binding.
Then Model Binding invokes before Action Filters and after Resource Filters(See Figure 1). But Middleware invokes before Filters(See Figure 2).
Figure 1:
Figure 2:
Reference:
How Filters work
Conclusion:
That is to say, you can not get the ModelState in UseExceptionHandler middleware.
Workaround:
You can only store the ModelState automatically in Filters(Action Filters or Exception Filters or Result Filters), then you can use it within middleware.
app.UseExceptionHandler(new ExceptionHandlerOptions()
{
ExceptionHandler = async (context) =>
{
var ex = context.Features.Get<IExceptionHandlerFeature>().Error;
// how to access to ModelState here
var data = context.Features.Get<ModelStateFeature>()?.ModelState;
}
});
Reference:
How to store ModelState automatically
Related
I have a legacy ASP.NET Web API 2 app which must be ported to ASP.NET Core 6 and it has the following behaviour:
Some controllers return responses in Pascal-case Json
Some controllers return responses in camel-case Json
All controllers have the same authentication/authorization, but they return different objects using different serializers for 401/403 cases.
In ASP.NET Web API 2 it was easily solved with IControllerConfiguration (to set the formatter for a controller), AuthorizeAttribute (to throw exceptions for 401/403), ExceptionFilterAttribute to set 401/403 status code and response which will be serialized using correct formatter.
In ASP.NET Core, it seems that IOutputFormatter collection is global for all controllers and it is not available during UseAuthentication + UseAuthorization pipeline where it terminates in case of failure.
Best I could come up with is to always "succeed" in authentication / authorization with some failing flag in claims and add IActionFilter as first filter checking those flags, but it looks very hacky.
Is there some better approach?
Update1:
Implementing different output formatters for IActionResult from controller or IFilter (including IExceptionFilter) is not very difficult.
What I want is to be able to either set IActionResult or use IOutputFormatter related to Action identified by UseRouting for Authentication/Authorization error or IAuthorizationHandler, but looks like all those auth steps are invoked before either ActionContext or IOutputFormatter is invoked.
So 2 approaches I see now:
hack auth code to "always pass" and handle HttpContext.Items["MyRealAuthResult"] object in IActionFilter
expose V1OutputFormatter/V2OutputFormatter in a static field and duplicate selection logic in HandleChallengeAsync/HandleForbiddenAsync based on to what controller/action it was routed from UseRouting step.
Here is sample app that uses auth and has 2 endpoints:
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddSingleton<IConfigureOptions<MvcOptions>, MvcOptionsSetup>();
builder.Services.AddAuthentication(options =>
{
options.AddScheme<DefAuthHandler>("defscheme", "defscheme");
});
builder.Services.AddAuthorization(options =>
options.DefaultPolicy = new AuthorizationPolicyBuilder("defscheme")
.RequireAssertion(context =>
// false here should result in Pascal case POCO for WeatherForecastV1Controller
// and camel case POCO for WeatherForecastV2Controller
context.User.Identities.Any(c => c.AuthenticationType == "secretheader"))
.Build())
.AddSingleton<IAuthorizationMiddlewareResultHandler, AuthorizationResultHandler>();
builder.Services.AddControllers();
var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
app.Run();
public class AuthorizationResultHandler : IAuthorizationMiddlewareResultHandler
{
private readonly AuthorizationMiddlewareResultHandler _handler;
public AuthorizationResultHandler()
{
_handler = new AuthorizationMiddlewareResultHandler();
}
public async Task HandleAsync(RequestDelegate next, HttpContext context, AuthorizationPolicy policy, PolicyAuthorizationResult authorizeResult)
{
// Can't set ActionContext.Response here or use IOutputFormatter
await _handler.HandleAsync(next, context, policy, authorizeResult);
}
}
public class DefAuthHandler : AuthenticationHandler<AuthenticationSchemeOptions>
{
public DefAuthHandler(IOptionsMonitor<AuthenticationSchemeOptions> options, ILoggerFactory logger, UrlEncoder encoder, ISystemClock clock)
: base(options, logger, encoder, clock) { }
protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
{
var claims = new List<ClaimsIdentity>();
if (Request.Headers.ContainsKey("secretheader")) claims.Add(new ClaimsIdentity("secretheader"));
return AuthenticateResult.Success(new AuthenticationTicket(new ClaimsPrincipal(claims), "defscheme"));
}
}
public class MvcOptionsSetup : IConfigureOptions<MvcOptions>
{
private readonly ArrayPool<char> arrayPool;
private readonly MvcNewtonsoftJsonOptions mvcNewtonsoftJsonOptions;
public MvcOptionsSetup(ArrayPool<char> arrayPool, IOptions<MvcNewtonsoftJsonOptions> mvcNewtonsoftJsonOptions)
{
this.arrayPool = arrayPool;
this.mvcNewtonsoftJsonOptions = mvcNewtonsoftJsonOptions.Value;
}
public void Configure(MvcOptions options)
{
options.OutputFormatters.Insert(0, new V1OutputFormatter(arrayPool, options, mvcNewtonsoftJsonOptions));
options.OutputFormatters.Insert(0, new V2OutputFormatter(arrayPool, options, mvcNewtonsoftJsonOptions));
}
}
public class V1OutputFormatter : NewtonsoftJsonOutputFormatter
{
public V1OutputFormatter(ArrayPool<char> charPool, MvcOptions mvcOptions, MvcNewtonsoftJsonOptions? jsonOptions)
: base(new JsonSerializerSettings { ContractResolver = new DefaultContractResolver() }, charPool, mvcOptions, jsonOptions) { }
public override bool CanWriteResult(OutputFormatterCanWriteContext context)
{
var controllerDescriptor = context.HttpContext.GetEndpoint()?.Metadata.GetMetadata<Microsoft.AspNetCore.Mvc.Controllers.ControllerActionDescriptor>();
return controllerDescriptor?.ControllerName == "WeatherForecastV1";
}
}
public class V2OutputFormatter : NewtonsoftJsonOutputFormatter
{
public V2OutputFormatter(ArrayPool<char> charPool, MvcOptions mvcOptions, MvcNewtonsoftJsonOptions? jsonOptions)
: base(new JsonSerializerSettings { ContractResolver = new CamelCasePropertyNamesContractResolver() }, charPool, mvcOptions, jsonOptions) { }
public override bool CanWriteResult(OutputFormatterCanWriteContext context)
{
var controllerDescriptor = context.HttpContext.GetEndpoint()?.Metadata.GetMetadata<Microsoft.AspNetCore.Mvc.Controllers.ControllerActionDescriptor>();
return controllerDescriptor?.ControllerName == "WeatherForecastV2";
}
}
[ApiController]
[Authorize]
[Route("v1/weatherforecast")]
public class WeatherForecastV1Controller : ControllerBase
{
[HttpGet]
public IActionResult Get()
{
// This must be Pascal case
return Ok(new WeatherForecast() { Summary = "summary" });
}
}
[ApiController]
[Authorize]
[Route("v2/weatherforecast")]
public class WeatherForecastV2Controller : ControllerBase
{
[HttpGet]
public IActionResult Get()
{
// This must be camel case
return Ok(new WeatherForecast() { Summary = "summary" });
}
}
If there is no way to configure controllers independently, then you could use some middleware to convert output from selected controllers that meet a path-based predicate.
var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
app.MapWhen(ctx => ctx.Request.Path.Containes("v2/"), cfg =>
{
app.UseMiddleware<JsonCapitalizer>();
});
app.Run();
And then create a JsonCapitalizer class to convert output from any path that contains "v2/". Note, this middleware will not run if the predicate in MapWhen is not satisfied.
public class JsonCapitalizer
{
readonly RequestDelegate _nextRequestDelegate;
public RequestLoggingMiddleware(
RequestDelegate nextRequestDelegate)
{
_nextRequestDelegate = nextRequestDelegate;
}
public async Task Invoke(HttpContext httpContext)
{
await _nextRequestDelegate(httpContext);
// Get the httpContext.Response
// Capitalize it
// Rewrite the response
}
}
There may be better ways, but that's the first that comes to mind.
The following link will help with manipulation of the response body:
https://itecnote.com/tecnote/c-how-to-read-asp-net-core-response-body/
I also faced such a problem in ASP Core 7 and ended up with writing an attribute.
So the attribute will be applied on each Action where the response type has to be converted. You can write many an attribute for camelcase response and another attribute for pascalcase. The attribute will look like below for CamelCase
public class CamelCaseAttribute : ActionFilterAttribute
{
private static readonly SystemTextJsonOutputFormatter formatter = new SystemTextJsonOutputFormatter(new()
{
ReferenceHandler = ReferenceHandler.IgnoreCycles,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
});
public override void OnActionExecuted(ActionExecutedContext context)
{
if (context.Result is ObjectResult objectResult)
{
objectResult.Formatters
.RemoveType<NewtonsoftJsonOutputFormatter>();
objectResult.Formatters.Add(formatter);
}
else
{
base.OnActionExecuted(context);
}
}
}
And on the Contoller Action you can use it like below
[CamelCase]
public async IAsyncEnumerable<ResponseResult<IReadOnlyList<VendorBalanceReportDto>>> VendorBalanceReport([FromQuery] Paginator paginator, [FromQuery] VendorBalanceReportFilter filter, [EnumeratorCancellation] CancellationToken token)
{
var response = _reportService.VendorBalanceReport(paginator, filter, token);
await foreach (var emailMessage in response)
{
yield return emailMessage;
}
}
I want to check user authorization in the api method.
Method responsible for get for an employee by id. So user should render this method if the user works the same company with employee. So I mean user CompanyId should be same with the Employee CompanyId.
Think about like this api method:
public async Task<IActionResult> GetEmployeeById([FromRoute]int id)
{
try
{
var entity = await _employeeRepository.GetAsync(p => p.Id == id);
if (entity == null)
{
return NotFound();
}
//user's CompanyId should be same with Employee CompanyId
var user = await _userManager.FindByIdAsync(User.Identity.Name);
if (user.CompanyId != eployee.CompanyId)
{
return Unauthorized();
}
return Ok(entity);
}
catch (Exception ex)
{
throw ex;
}
}
I have to check user company in all api methods.
So I want to do this by Aspect Oriented Programming.So method should be like this after AOP implementation:
[CheckUserCompany]
public async Task<IActionResult> GetEmployeeById([FromRoute]int id)
{
try
{
var entity = await _employeeRepository.GetAsync(p => p.Id == id);
if (entity == null)
{
return NotFound();
}
return Ok(entity);
}
catch (Exception ex)
{
throw ex;
}
}
How can I do this with Aspect Oriented Programming in Asp.Net Core 3.1 Web Api?
Thanks
You could customize a ActionFilter like below:
public class CheckUserCompany:IActionFilter
{
private readonly ApplicationDbContext _dbcontext;
private readonly UserManager<ApplicationUser> _userManager;
public CheckUserCompany(ApplicationDbContext dbcontext, UserManager<ApplicationUser> userManager)
{
_dbcontext = dbcontext;
_userManager = userManager;
}
// Do something before the action executes.
public void OnActionExecuting(ActionExecutingContext context)
{
var id = (int)context.ActionArguments["id"];
var employee = _dbcontext.Employee.Find(id);
var user = _userManager.FindByNameAsync(context.HttpContext.User.Identity.Name);
if (user.Result.CompanyId != employee.CompanyId)
{
throw new UnauthorizedAccessException();
}
return;
}
// Do something after the action executes.
public void OnActionExecuted(ActionExecutedContext context)
{
}
}
If we want to use our filter as a service type on the Action or Controller level, we need to register it in the same ConfigureServices method but as a service in the IoC container:
services.AddScoped<CheckUserCompany>();
Finally, to use a filter registered on the Action or Controller level, we need to place it on top of the Controller or Action as a ServiceType:
[ServiceFilter(typeof(CheckUserCompany))]
public async Task<IActionResult> GetEmployeeById([FromRoute]int id)
Summary
I am trying to add security/authentication to my SignalR hubs, but no matter what I try the client requests keep getting a 403 - Forbidden responses (despite the requests successfully authenticating).
Setup
My project is based on Microsoft's SignalRChat example from:
https://learn.microsoft.com/en-us/aspnet/core/tutorials/signalr?view=aspnetcore-3.1&tabs=visual-studio
Basically I have an ASP.Net Core web application with Razor Pages. The project is targeting .Net Core 3.1.
The client library being used is v3.1.0 of Microsoft's JavaScript client library.
I also referenced their authentication and authorization document for the security side:
https://learn.microsoft.com/en-us/aspnet/core/signalr/authn-and-authz?view=aspnetcore-3.1
The key difference is rather than using the JWT Bearer middleware, I made my own custom token authentication handler.
Code
chat.js:
"use strict";
var connection = new signalR.HubConnectionBuilder()
.withUrl("/chatHub", { accessTokenFactory: () => 'mytoken' })
.configureLogging(signalR.LogLevel.Debug)
.build();
//Disable send button until connection is established
document.getElementById("sendButton").disabled = true;
connection.on("ReceiveMessage", function (user, message) {
var msg = message.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
var encodedMsg = user + " says " + msg;
var li = document.createElement("li");
li.textContent = encodedMsg;
document.getElementById("messagesList").appendChild(li);
});
connection.start().then(function () {
document.getElementById("sendButton").disabled = false;
}).catch(function (err) {
return console.error(err.toString());
});
document.getElementById("sendButton").addEventListener("click", function (event) {
var user = document.getElementById("userInput").value;
var message = document.getElementById("messageInput").value;
connection.invoke("SendMessage", user, message).catch(function (err) {
return console.error(err.toString());
});
event.preventDefault();
});
Startup.cs
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using SignalRChat.Hubs;
using SignalRChat.Security;
namespace SignalRChat
{
public class Startup
{
public Startup(IConfiguration configuration)
{
Configuration = configuration;
}
public IConfiguration Configuration { get; }
// This method gets called by the runtime. Use this method to add services to the container.
public void ConfigureServices(IServiceCollection services)
{
// Found other cases where CORS caused authentication issues, so
// making sure that everything is allowed.
services.AddCors(options =>
{
options.AddPolicy("AllowAny", policy =>
{
policy
.WithOrigins("http://localhost:44312/", "https://localhost:44312/")
.AllowCredentials()
.AllowAnyHeader()
.AllowAnyMethod();
});
});
services
.AddAuthentication()
.AddHubTokenAuthenticationScheme();
services.AddAuthorization(options =>
{
options.AddHubAuthorizationPolicy();
});
services.AddRazorPages();
services.AddSignalR(options =>
{
options.EnableDetailedErrors = true;
});
}
// This method gets called by the runtime. Use this method to configure the HTTP request pipeline.
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
if (env.IsDevelopment())
{
app.UseDeveloperExceptionPage();
}
else
{
app.UseExceptionHandler("/Error");
// The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts.
app.UseHsts();
}
app.UseHttpsRedirection();
app.UseStaticFiles();
app.UseRouting();
app.UseAuthentication();
app.UseAuthorization();
app.UseEndpoints(endpoints =>
{
endpoints.MapRazorPages();
endpoints.MapHub<ChatHub>("/chatHub");
});
}
}
}
ChatHub.cs
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.SignalR;
using SignalRChat.Security;
using System.Threading.Tasks;
namespace SignalRChat.Hubs
{
[Authorize(HubRequirementDefaults.PolicyName)]
public class ChatHub : Hub
{
public async Task SendMessage(string user, string message)
{
await Clients.All.SendAsync("ReceiveMessage", user, message);
}
}
}
HubTokenAuthenticationHandler.cs
using Microsoft.AspNetCore.Authentication;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using System;
using System.Security.Claims;
using System.Text.Encodings.Web;
using System.Threading.Tasks;
namespace SignalRChat.Security
{
public class HubTokenAuthenticationHandler : AuthenticationHandler<HubTokenAuthenticationOptions>
{
public IServiceProvider ServiceProvider { get; set; }
public HubTokenAuthenticationHandler(
IOptionsMonitor<HubTokenAuthenticationOptions> options,
ILoggerFactory logger,
UrlEncoder encoder,
ISystemClock clock,
IServiceProvider serviceProvider)
: base(options, logger, encoder, clock)
{
ServiceProvider = serviceProvider;
}
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
{
bool isValid = TryAuthenticate(out AuthenticationTicket ticket, out string message);
if (isValid) return Task.FromResult(AuthenticateResult.Success(ticket));
return Task.FromResult(AuthenticateResult.Fail(message));
}
private bool TryAuthenticate(out AuthenticationTicket ticket, out string message)
{
message = null;
ticket = null;
var token = GetToken();
if (string.IsNullOrEmpty(token))
{
message = "Token is missing";
return false;
}
bool tokenIsValid = token.Equals("mytoken");
if (!tokenIsValid)
{
message = $"Token is invalid: token={token}";
return false;
}
var claims = new[] { new Claim("token", token) };
var identity = new ClaimsIdentity(claims, nameof(HubTokenAuthenticationHandler));
ticket = new AuthenticationTicket(new ClaimsPrincipal(identity), Scheme.Name);
return true;
}
#region Get Token
private string GetToken()
{
string token = Request.Query["access_token"];
if (string.IsNullOrEmpty(token))
{
token = GetTokenFromHeader();
}
return token;
}
private string GetTokenFromHeader()
{
string token = Request.Headers["Authorization"];
if (string.IsNullOrEmpty(token)) return null;
// The Authorization header value should be in the format "Bearer [token_value]"
string[] authorizationParts = token.Split(new char[] { ' ' });
if (authorizationParts == null || authorizationParts.Length < 2) return token;
return authorizationParts[1];
}
#endregion
}
}
HubTokenAuthenticationOptions.cs
using Microsoft.AspNetCore.Authentication;
namespace SignalRChat.Security
{
public class HubTokenAuthenticationOptions : AuthenticationSchemeOptions { }
}
HubTokenAuthenticationDefaults.cs
using Microsoft.AspNetCore.Authentication;
using System;
namespace SignalRChat.Security
{
public static class HubTokenAuthenticationDefaults
{
public const string AuthenticationScheme = "HubTokenAuthentication";
public static AuthenticationBuilder AddHubTokenAuthenticationScheme(this AuthenticationBuilder builder)
{
return AddHubTokenAuthenticationScheme(builder, (options) => { });
}
public static AuthenticationBuilder AddHubTokenAuthenticationScheme(this AuthenticationBuilder builder, Action<HubTokenAuthenticationOptions> configureOptions)
{
return builder.AddScheme<HubTokenAuthenticationOptions, HubTokenAuthenticationHandler>(AuthenticationScheme, configureOptions);
}
}
}
HubRequirement.cs
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.SignalR;
using System.Threading.Tasks;
namespace SignalRChat.Security
{
public class HubRequirement : AuthorizationHandler<HubRequirement, HubInvocationContext>, IAuthorizationRequirement
{
protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, HubRequirement requirement, HubInvocationContext resource)
{
// Authorization logic goes here. Just calling it a success for demo purposes.
context.Succeed(requirement);
return Task.CompletedTask;
}
}
}
HubRequirementDefaults.cs
using Microsoft.AspNetCore.Authentication;
using System;
namespace SignalRChat.Security
{
public static class HubTokenAuthenticationDefaults
{
public const string AuthenticationScheme = "HubTokenAuthentication";
public static AuthenticationBuilder AddHubTokenAuthenticationScheme(this AuthenticationBuilder builder)
{
return AddHubTokenAuthenticationScheme(builder, (options) => { });
}
public static AuthenticationBuilder AddHubTokenAuthenticationScheme(this AuthenticationBuilder builder, Action<HubTokenAuthenticationOptions> configureOptions)
{
return builder.AddScheme<HubTokenAuthenticationOptions, HubTokenAuthenticationHandler>(AuthenticationScheme, configureOptions);
}
}
}
The Results
On the client side, I see the following errors in the browser's developer console:
POST https://localhost:44312/chatHub/negotiate?negotiateVersion=1 403
Error: Failed to complete negotiation with the server: Error
Error: Failed to start the connection: Error
On the server side, all I see is:
SignalRChat.Security.HubTokenAuthenticationHandler: Debug: AuthenticationScheme: HubTokenAuthentication was successfully authenticated.
SignalRChat.Security.HubTokenAuthenticationHandler: Information:
AuthenticationScheme: HubTokenAuthentication was forbidden.
Next Steps
I did see that others had issues with CORS preventing them from security from working, but I believe it usually said that explicitly in the client side errors. Despite that, I added the CORS policies in Startup.cs that I believe should have circumvented that.
I also experimented around with changing the order of service configurations in Startup, but nothing seemed to help.
If I remove the Authorize attribute (i.e. have an unauthenticated hub) everything works fine.
Finally, I found the server side messages to be very interesting in that the authentication succeeded, yet the request was still forbidden.
I'm not really sure where to go from here. Any insights would be most appreciated.
Update
I have been able to debug this a little bit.
By loading system symbols and moving up the call stack, I found my way to Microsoft.AspNetCore.Authorization.Policy.PolicyEvaluator:
As can be seen, the authentication succeeded but apparently the authorization did not. Looking at the requirements, there are two: a DenyAnonymousAuthorizationRequirement and my HubRequirement (which automatically succeeds).
Because the debugger never hit my breakpoint in my HubRequirement class, I am left to assume that the DenyAnonymousAuthorizationRequirement is what is failing. Interesting, because based on the code listing on github (https://github.com/dotnet/aspnetcore/blob/master/src/Security/Authorization/Core/src/DenyAnonymousAuthorizationRequirement.cs) I should be meeting all the requirements:
There is a User defined on the context, the user has an identity, and there are no identities that are unauthenticated.
I have to be missing something, because this isn't adding up.
Turns out the failure was actually happening in my HubRequirement class, and not DenyAnonymousAuthorizationRequirement.
While my HubRequirement class implemented HandleRequirementAsync(), it did not implement HandleAsync(), which is what happened to be what was called instead.
If I update my HubRequirement class to the following, everything works as expected:
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.SignalR;
using System.Threading.Tasks;
namespace SignalRChat.Security
{
public class HubRequirement : AuthorizationHandler<HubRequirement, HubInvocationContext>, IAuthorizationRequirement
{
public override Task HandleAsync(AuthorizationHandlerContext context)
{
foreach (var requirement in context.PendingRequirements)
{
// TODO: Validate each requirement
}
// Authorization logic goes here. Just calling it a success for demo purposes.
context.Succeed(this);
return Task.CompletedTask;
}
protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, HubRequirement requirement, HubInvocationContext resource)
{
// Authorization logic goes here. Just calling it a success for demo purposes.
context.Succeed(requirement);
return Task.CompletedTask;
}
}
}
Thank you, saved me a lot of debugging hours!
Looks like the problem is that HandleAsync is also being called with a RouteEndpoint resource for the signalr root and negotiation urls, a case the base class does not handle and since no authorization handler signals success it fails.
public override async Task HandleAsync(AuthorizationHandlerContext context)
{
if (context.Resource is HubInvocationContext)
{
foreach (var req in context.Requirements.OfType<RealtimeHubSecurityAuthorizationHandler>())
{
await HandleRequirementAsync(context, req, (HubInvocationContext)context.Resource);
}
} else if (context.Resource is Microsoft.AspNetCore.Routing.RouteEndpoint) {
//allow signalr root and negotiation url
context.Succeed(this);
}
}
(posted as answer since comment length is limited, sorry)
Is there a way to handle asp.net core odata errors?
I have a model class DimDateAvailable with one property, a primary key of int DateId, and I make a call like /data/DimDateAvailable?$select=test.
Other calls work as expected and return what I'm after - this is a deliberate call to generate a fault, and it fails because there is no property named test on the model. The response comes back as expected, like so: {"error":{"code":"","message":"The query specified in the URI is not valid. Could not find a property named 'test' on type 'DimDateAvailable'... followed by a stack trace.
This response is fine when env.IsDevelopment() is true but I don't want to expose the stack trace when not in development.
I've looked at wrapping the code in the controllers' get method in a try-catch, but I think there's an action filter running over the results so it never gets called. On the other hand, I can't see where to inject any middleware and/or add any filters to catch errors. I suspect there might be a way to override an output formatter to achieve what I want but I can't see how.
Here's what I have at the moment:
In Startup.cs:
public void ConfigureServices(IServiceCollection services)
{
services.AddScoped<TelemetryDbContext>();
services.AddOData();
services.AddMvc();
}
public void Configure(IApplicationBuilder app, IHostingEnvironment env)
{
if (env.IsDevelopment())
{
app.UseDeveloperExceptionPage();
}
app.UseMvc(routeBuilder =>
{
routeBuilder.MapODataServiceRoute("odata", "data", GetEdmModel());
routeBuilder.Select().Expand().Filter().OrderBy().MaxTop(null).Count();
// insert special bits for e.g. custom MLE here
routeBuilder.EnableDependencyInjection();
});
}
private static IEdmModel GetEdmModel()
{
var builder = new ODataConventionModelBuilder();
builder.EntitySet<DimDateAvailable>("DimDateAvailable");
return builder.GetEdmModel();
}
In TelemetryDbContext.cs:
public virtual DbSet<DimDateAvailable> DimDateAvailable { get; set; }
In DimDateAvailable.cs
public class DimDateAvailable
{
[Key]
public int DateId { get; set; }
}
My controller:
public class DimDateAvailableController : ODataController
{
private readonly TelemetryDbContext data;
public DimDateAvailableController(TelemetryDbContext data)
{
this.data = data;
}
[EnableQuery(AllowedQueryOptions = AllowedQueryOptions.Supported, PageSize = 2000)]
public IActionResult Get()
{
return Ok(this.data.DimDateAvailable.AsQueryable());
}
}
This is in an asp.net core 2 web app with the Microsoft.AspNetCoreOData v7.0.1 and EntityFramework 6.2.0 packages.
Investigating Ihar's suggestion lead me down the rabbit hole, and I ended up inserting an ODataOutputFormatter into the MVC options to intercept ODataPayloadKind.Error responses and reformat them.
It was interesting to see that context.Features held an instance of IExceptionHandlerFeature in app.UseExceptionHandler() but not in the ODataOutputFormatter. That lack was pretty much what prompted me to pose this question in the first place, but was solved by translating the context.Object in the ODataOutputFormatter which is something I saw done in the OData source as well. I don't know if the changes below are good practice in asp.net core or when using the AspNetCoreOData package, but they do what I want for now.
Changes to Startup.cs
public void ConfigureServices(IServiceCollection services)
{
services.AddScoped<TelemetryDbContext>();
services.AddOData();
services.AddMvc(options =>
{
options.OutputFormatters.Insert(0, new CustomODataOutputFormatter(this.Environment.IsDevelopment()));
});
}
public void Configure(IApplicationBuilder app, IHostingEnvironment env)
{
if (env.IsDevelopment())
{
app.UseDeveloperExceptionPage();
}
// Added this to catch errors in my own code and return them to the client as ODataErrors
app.UseExceptionHandler(appBuilder =>
{
appBuilder.Use(async (context, next) =>
{
var error = context.Features[typeof(IExceptionHandlerFeature)] as IExceptionHandlerFeature;
if (error?.Error != null)
{
context.Response.StatusCode = (int)HttpStatusCode.InternalServerError;
context.Response.ContentType = "application/json";
var response = error.Error.CreateODataError(!env.IsDevelopment());
await context.Response.WriteAsync(JsonConvert.SerializeObject(response));
}
// when no error, do next.
else await next();
});
});
app.UseMvc(routeBuilder =>
{
routeBuilder.MapODataServiceRoute("odata", "data", GetEdmModel());
routeBuilder.Select().Expand().Filter().OrderBy().MaxTop(null).Count();
// insert special bits for e.g. custom MLE here
routeBuilder.EnableDependencyInjection();
});
}
New classes CustomODataOutputFormatter.cs and CommonExtensions.cs
public class CustomODataOutputFormatter : ODataOutputFormatter
{
private readonly JsonSerializer serializer;
private readonly bool isDevelopment;
public CustomODataOutputFormatter(bool isDevelopment)
: base(new[] { ODataPayloadKind.Error })
{
this.serializer = new JsonSerializer { ContractResolver = new CamelCasePropertyNamesContractResolver() };
this.isDevelopment = isDevelopment;
this.SupportedMediaTypes.Add("application/json");
this.SupportedEncodings.Add(new UTF8Encoding());
}
public override Task WriteResponseBodyAsync(OutputFormatterWriteContext context, Encoding selectedEncoding)
{
if (!(context.Object is SerializableError serializableError))
{
return base.WriteResponseBodyAsync(context, selectedEncoding);
}
var error = serializableError.CreateODataError(this.isDevelopment);
using (var writer = new StreamWriter(context.HttpContext.Response.Body))
{
this.serializer.Serialize(writer, error);
return writer.FlushAsync();
}
}
}
public static class CommonExtensions
{
public const string DefaultODataErrorMessage = "A server error occurred.";
public static ODataError CreateODataError(this SerializableError serializableError, bool isDevelopment)
{
// ReSharper disable once InvokeAsExtensionMethod
var convertedError = SerializableErrorExtensions.CreateODataError(serializableError);
var error = new ODataError();
if (isDevelopment)
{
error = convertedError;
}
else
{
// Sanitise the exposed data when in release mode.
// We do not want to give the public access to stack traces, etc!
error.Message = DefaultODataErrorMessage;
error.Details = new[] { new ODataErrorDetail { Message = convertedError.Message } };
}
return error;
}
public static ODataError CreateODataError(this Exception ex, bool isDevelopment)
{
var error = new ODataError();
if (isDevelopment)
{
error.Message = ex.Message;
error.InnerError = new ODataInnerError(ex);
}
else
{
error.Message = DefaultODataErrorMessage;
error.Details = new[] { new ODataErrorDetail { Message = ex.Message } };
}
return error;
}
}
Changes to the controller:
[EnableQuery(AllowedQueryOptions = AllowedQueryOptions.Supported, PageSize = 2000)]
public IQueryable<DimDateAvailable> Get()
{
return this.data.DimDateAvailable.AsQueryable();
}
If you want a customization of responses, including customization of error responses try to use ODataQueryOptions instead of using
[EnableQuery(AllowedQueryOptions = AllowedQueryOptions.Supported, PageSize = 2000)]
Check some samples at https://learn.microsoft.com/en-us/aspnet/web-api/overview/odata-support-in-aspnet-web-api/supporting-odata-query-options#invoking-query-options-directly
It would allow you to cache validation errors and build custom response.
I have had this issue in the past and the only one way I got this working without having to write a middleware was like:
Try this:
catch (ODataException ex)
{
HttpContext.Response.StatusCode = StatusCodes.Status400BadRequest;//This line is important, if not it will return 500 Internal Server Error.
return BadRequest(ex.Message);//Just respond back the actual error which is 100% correct.
}
Then the error will look like:
{
"#odata.context": "http://yourendpoint.com$metadata#Edm.String",
"value": "The property 'test' cannot be used in the $select query option."
}
Hope this helps.
Thanks
I have setup Serilog to log to MSSql using:
Log.Logger = new LoggerConfiguration()
.MinimumLevel.Debug()
.MinimumLevel.Override("Microsoft", LogEventLevel.Information)
.MinimumLevel.Override("System", LogEventLevel.Information)
.MinimumLevel.Override("Microsoft.AspNetCore.Authentication", LogEventLevel.Information)
.Enrich.FromLogContext()
.WriteTo.Async(x => x.MSSqlServer(logConntectionString, tableName, LogEventLevel.Warning, autoCreateSqlTable: false, columnOptions: columnOptions))
.CreateLogger();
Additionally I have added added a SerilogMiddleware in the pipeline that successfully adds LogContext from the HttpContext.
In a test controller, I have these 2 test methods:
public class TestController : ControllerBase
{
[HttpGet, Route("test")]
public IActionResult Get() {
try
{
string[] sar = new string[0];
var errorgenerator = sar[2]; // Trigger exception
}
catch (Exception ex)
{
Log.Error(ex, "Caught Exception");
return StatusCode(500, "Custom 500 Error");
}
return Ok();
}
[HttpGet, Route("test2")]
public IActionResult Get2() {
string[] sar = new string[0];
var errorgenerator = sar[2];// Trigger exception
return Ok();
}
}
The first method is not DRY, and so I would like to handle global/uncaught exceptions such as method 2.
What I have from here is:
public class GloablExceptionFilter : ActionFilterAttribute, IExceptionFilter
{
public void OnException(ExceptionContext context)
{
var httpContext = context.HttpContext; // This does not appear to have the actual HttpContext
Log.Error(context.Exception, "Unhandled Exception");
}
}
Problem is, my middleware that otherwise worked no longer does.. It does not edit the response body, etc... Further, when I access ExceptionContext's context.HttpContext, it does not contain the actual HttpContext when triggered from inside a controller method such as above.
How do I inject or share HttpContext and or LogContext with this Filter?
If thats not possible, how do I accomplish logging exceptions, while being DRY, and having context when its available?
Update 1: Current Middleware
public void Configure(IApplicationBuilder app, IHostingEnvironment env, ILoggerFactory loggerFactory)
{
loggerFactory.AddSerilog();
app.UseAuthentication();
// Logging Middleware is just after Authentication, to have access to
// user IsAuthorized, claims, etc..
app.UseMiddleware<SerilogMiddleware>();
app.UseCors("CORSPolicy");
app.UseMvc();
}
In the middleware itself:
public class SerilogMiddleware
{
readonly RequestDelegate _next;
public SerilogMiddleware(RequestDelegate next)
{
if (next == null) throw new ArgumentNullException(nameof(next));
_next = next;
}
public async Task Invoke(HttpContext httpContext)
{
// Do logging stuff with Request..
await _next(httpContext);
// Do logging stuff with Response but..
// This point is never reached, when exception is unhandled.
}
}
Based on code snippet you are not catching the exception when you pass the context down the pipeline.
If you do not catch/handle the exception within the middleware then it wont reach your code after calling down stream.
public class SerilogMiddleware {
readonly RequestDelegate _next;
public SerilogMiddleware(RequestDelegate next) {
if (next == null) throw new ArgumentNullException(nameof(next));
_next = next;
}
public async Task Invoke(HttpContext httpContext) {
// Do logging stuff with Request..
try {
await _next(httpContext);
} catch(Exception ex) {
try {
//Do exception specific logging
// if you don't want to rethrow the original exception
// then call return:
// return;
} catch (Exception loggingException) {
//custom
}
// Otherwise re -throw the original exception
throw;
}
// Do logging stuff with Response
}
}
The above will re-throw the original error after logging it so that the other handler in the pipeline will catch it and do the out of the box handling.