Is It Possible to Dynamically Add SwaggerEndpoints For SwaggerUI? - asp.net-core

We're building out a services oriented architecture in .NET Core. We've decided to use Ocelot as our API gateway. I have integrated Ocelot with Consul for service discovery. Now I'm trying to attempt to create a unified Swagger UI for all the downstream services.
Prior to service discovery we had Swagger setup like this:
// Enable middleware to serve generated Swagger as a JSON endpoint
app.UseSwagger(c => { c.RouteTemplate = "{documentName}/swagger.json"; });
// Enable middleware to serve swagger-ui assets (HTML, JS, CSS etc.)
app.UseSwaggerUI(c =>
{
c.SwaggerEndpoint("/docs/customer/swagger.json", "Customers Api Doc");
c.SwaggerEndpoint("/docs/employee/swagger.json", "Employee Api Doc");
c.SwaggerEndpoint("/docs/report/swagger.json", "Reports Api Doc");
});
On the Swagger UI this provides a "select a spec" dropdown. The developers like this functionality and we'd like to keep it. However, now that we've removed the manual configuration in favor of service discovery we would also like to have these endpoints dynamically updated.
With the current Swagger solution that's available is this possible? I haven't seen anything relating to service discovery or being able to dynamically configure the UI. Thoughts and suggestions?
Update
I've come up with a way to do this. It is a bit hack-ish and I'm hoping there is a way to do this that isn't so heavy handed.
public class Startup
{
static object LOCK = new object();
SwaggerUIOptions options;
public void ConfigureServices(IServiceCollection services)
{
services.AddSingleton<SwaggerUIOptions>((provider) =>
{
return this.options;
});
services.AddSingleton<IHostedService, SwaggerUIDocsAggregator>();
services.AddSingleton<IConsulDiscoveryService, MyCounsulDiscoveryServiceImplementation>();
}
public void Configure(IApplicationBuilder app, IHostingEnvironment env)
{
// Enable middleware to serve generated Swagger as a JSON endpoint
app.UseSwagger(c => { c.RouteTemplate = "{documentName}/swagger.json"; });
// Enable middleware to serve swagger-ui assets (HTML, JS, CSS etc.)
app.UseSwaggerUI(c =>
{
this.options = c;
});
}
}
public class SwaggerUIDocsAggregator : IHostedService
{
static object LOCK = new object();
IConsulDiscoveryService discoveryService;
SwaggerUIOptions options;
Timer timer;
bool polling = false;
int pollingInterval = 600;
public ConsulHostedService(IConsulDiscoveryService discoveryService, SwaggerUIOptions options)
{
this.discoveryService = discoveryService;
this.options = options;
}
public async Task StartAsync(CancellationToken cancellationToken)
{
this.timer = new Timer(async x =>
{
if (this.polling)
{
return;
}
lock (LOCK)
{
this.polling = true;
}
await this.UpdateDocs();
lock (LOCK)
{
this.polling = false;
}
}, null, 0, pollingInterval);
}
public async Task StopAsync(CancellationToken cancellationToken)
{
this.timer.Dispose();
this.timer = null;
}
private async Task UpdateDocs()
{
var discoveredServices = await this.discoveryService.LookupServices();
var urls = new JArray();
foreach (var kvp in discoveredServices)
{
var serviceName = kvp.Key;
if (!urls.Any(u => (u as JObject).GetValue("url").Value<string>().Equals($"/{serviceName}/docs/swagger.json")))
{
urls.Add(JObject.FromObject(new { url = $"/{serviceName}/docs/swagger.json", name = serviceName }));
}
}
this.options.ConfigObject["urls"] = urls;
}
}

Easy way for integration Ocelot api gateway as a unified Swagger UI for all the downstream services is project MMLib.SwaggerForOcelot.

Related

Hangfire Dashboard Auth inside Web API project

We have an .NET 5.0 Web API project with as frontend an Angular project.
In de Web API we use Hangfire to do some jobs. I'm trying to make it work so that our admins can access the hangfire dashboard to be able to check the jobs. So I followed the documentation to do this (https://docs.hangfire.io/en/latest/configuration/using-dashboard.html). The Owin package does not seem to work with our Web API project so I've tried many other options such as added middleware, without Owen, changing the order of UseAuthentication and others.
The problem is that the HttpContext is always mostly empty and so the User is also empty.
As I see it the problem is that it is a Web API and not a MVC project as you see in many online examples. My knowledge of auth is also not that great so any help is welcome!
Some more information:
We use Azure AD as Authentication service
StartUp
public void Configure(IApplicationBuilder app, IWebHostEnvironment env, ILoggerFactory loggerFactory, IServiceProvider sp)
{
UseHangfireDashboardCustom(app);
app.UseHangfireServer();
app.UseHangfireDashboard("/hangfire");
app.UseAuthentication();
app.UseAuthorization();
}
private static IApplicationBuilder UseHangfireDashboardCustom(IApplicationBuilder app, string pathMatch = "/hangfire", DashboardOptions options = null, JobStorage storage = null)
{
var services = app.ApplicationServices;
storage = storage ?? services.GetRequiredService<JobStorage>();
options = options ?? services.GetService<DashboardOptions>() ?? new DashboardOptions();
var routes = app.ApplicationServices.GetRequiredService<RouteCollection>();
app.Map(new PathString(pathMatch), x =>
x.UseMiddleware<CustomHangfireDashboardMiddleware>(storage, options, routes));
return app;
}
CustomHangfireDashboardMiddleware
public class CustomHangfireDashboardMiddleware
{
private readonly RequestDelegate _nextRequestDelegate;
private readonly JobStorage _jobStorage;
private readonly DashboardOptions _dashboardOptions;
private readonly RouteCollection _routeCollection;
public CustomHangfireDashboardMiddleware(RequestDelegate nextRequestDelegate,
JobStorage storage,
DashboardOptions options,
RouteCollection routes)
{
_nextRequestDelegate = nextRequestDelegate;
_jobStorage = storage;
_dashboardOptions = options;
_routeCollection = routes;
}
public async Task Invoke(HttpContext httpContext)
{
var aspNetCoreDashboardContext = new AspNetCoreDashboardContext(_jobStorage, _dashboardOptions, httpContext);
var findResult = _routeCollection.FindDispatcher(httpContext.Request.Path.Value);
if (findResult == null)
{
await _nextRequestDelegate.Invoke(httpContext);
return;
}
// Attempt to authenticate against Cookies scheme.
// This will attempt to authenticate using data in request, but doesn't send challenge.
var result = await httpContext.AuthenticateAsync();
if (!result.Succeeded)
{
// Request was not authenticated, send challenge and do not continue processing this request.
await httpContext.ChallengeAsync();
return;
}
if (_dashboardOptions.Authorization.Any(filter => filter.Authorize(aspNetCoreDashboardContext) == false))
{
var isAuthenticated = result.Principal?.Identity?.IsAuthenticated ?? false;
if (isAuthenticated == false)
{
httpContext.Response.StatusCode = (int)HttpStatusCode.Unauthorized;
}
else
{
httpContext.Response.StatusCode = (int)HttpStatusCode.Forbidden;
}
return;
}
aspNetCoreDashboardContext.UriMatch = findResult.Item2;
await findResult.Item1.Dispatch(aspNetCoreDashboardContext);
}
}

How to configure Anti-Forgery Protection in a view-less Web API

I'm implementing a REST API using ASP.NET Core. It is stateless except for the fact that is uses cookies for authentication and therefore is vulnerable to cross-site request forgery (CSRF) attacks.
Luckily, ASP.NET Core provides means as a protection against that: Prevent Cross-Site Request Forgery (XSRF/CSRF) attacks in ASP.NET Core.
As my application does not have any views or pages, I'm only configuring my controllers using services.AddControllers() in my Startup.
When hitting a REST endpoint that is attributed with [ValidateAntiForgeryToken], I get the following exception:
System.InvalidOperationException: No service for type 'Microsoft.AspNetCore.Mvc.ViewFeatures.Filters.ValidateAntiforgeryTokenAuthorizationFilter' has been registered.
Registering my controllers using services.AddControllersWithViews() makes this error go away as it internally registers the appropriate service.
According to the docs:
Antiforgery middleware is added to the Dependency injection container when one of the following APIs is called in Startup.ConfigureServices:
AddMvc
MapRazorPages
MapControllerRoute
MapBlazorHub
All of these method seem to me to be view-centric (except MapControllerRoute which I'm doing in the Configure method in my Startup but it doesn't help) and part of the namespace of the missing service is ViewFeatures. This confuses me because in my understanding, and need to take care of CSRF although I'm developing a pure Web API without views.
Is my understanding wrong? How is CSRF protection configured when no views are involved?
I will suggest move away from the default ValidateAntiForgeryToken attribute
All the harder work is done by services.AddAntiforgery(), and the ValidateAntiForgeryToken just calls antiforgery.ValidateRequestAsync()
You can create your own filter for it and register it etc. but take a look at this neat implementation, you can simply inject an instance of IAntiforgery in all the POST api methods
https://github.com/dotnet/AspNetCore.Docs/blob/main/aspnetcore/security/anti-request-forgery/sample/AngularSample/Startup.cs
Here are what I believe to be bits of the Microsoft docs you link to on how to handle this. They say that "using local storage to store the antiforgery token on the client and sending the token as a request header is a recommended approach." They also go on to say that the approach is to use middleware to generate an antiforgery token and send it in the response as a cookie. In short they are saying if you have an API put the antiforgery token in a cookie.
As you say with just AddControllers you can't use the [ValidateAntiForgeryToken]. As LarryX says the thing to do is create your own filter.
In case it helps anyone I have created a demo app that uses some custom middleware to check for the antiforgery token if the request is not a GET.
Note that the CORS code is just there so that I could make a post from another domain to test the code works (I tested with https://localhost:44302).
Standard Program.cs (nothing interesting here)
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.Hosting;
namespace SpaAntiforgery
{
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<Startup>();
});
}
}
Startup.cs
using Microsoft.AspNetCore.Antiforgery;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using System;
namespace SpaAntiforgery
{
public class Startup
{
public Startup(IConfiguration configuration)
{
Configuration = configuration;
}
public IConfiguration Configuration { get; }
public void ConfigureServices(IServiceCollection services)
{
services.AddCors();
services.AddControllers();
services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
.AddCookie();
services.AddAntiforgery(options => options.HeaderName = "X-CSRF-TOKEN");
}
public void Configure(IApplicationBuilder app, IAntiforgery antiforgery)
{
//CORS code that is needed if you want another domain to access your API
app.UseCors(
options => options.WithOrigins("https://localhost:44302")
.AllowAnyMethod()
.AllowCredentials()
.WithHeaders("x-csrf-token", "content-type"));
app.UseRouting();
app.UseAuthentication();
app.UseAuthorization();
//this bit is straight form the Microsoft docs. See the link reference at the start of my answer
app.Use(next => context =>
{
string path = context.Request.Path.Value;
if (
string.Equals(path, "/", StringComparison.OrdinalIgnoreCase) ||
string.Equals(path, "/index.html", StringComparison.OrdinalIgnoreCase))
{
// The request token can be sent as a JavaScript-readable cookie,
var tokens = antiforgery.GetAndStoreTokens(context);
context.Response.Cookies.Append("XSRF-TOKEN", tokens.RequestToken,
new CookieOptions() { HttpOnly = false });
}
return next(context);
});
//this is my custom middleware that will test for the antiforgery token if the request is not a GET
app.EnsureAntiforgeryTokenPresentOnPosts();
app.UseEndpoints(endpoints =>
{
endpoints.MapControllerRoute(
name: "default",
pattern: "{controller=Home}/{action=Index}/{id?}");
endpoints.MapFallbackToController("Index", "Home");
});
}
}
}
Here is the custommiddleware code that is needed for app.EnsureAntiforgeryTokenPresentOnPosts();
using Microsoft.AspNetCore.Antiforgery;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using System;
using System.Threading.Tasks;
namespace SpaAntiforgery
{
public class AppEnsureAntiforgeryTokenPresentOnPostsMiddleware
{
private readonly RequestDelegate _next;
private readonly IAntiforgery _antiforgery;
public AppEnsureAntiforgeryTokenPresentOnPostsMiddleware(RequestDelegate next, IAntiforgery antiforgery)
{
_next = next;
_antiforgery = antiforgery;
}
public async Task Invoke(HttpContext httpContext)
{
var notAGetRerquest = !string.Equals("GET", httpContext.Request.Method, StringComparison.OrdinalIgnoreCase);
if (notAGetRerquest)
{
// This will throw if the token is invalid.
await _antiforgery.ValidateRequestAsync(httpContext);
}
await _next(httpContext);
}
}
public static class AppEnsureAntiforgeryTokenPresentOnPostsExtension
{
public static IApplicationBuilder EnsureAntiforgeryTokenPresentOnPosts(
this IApplicationBuilder builder)
{
return builder.UseMiddleware<AppEnsureAntiforgeryTokenPresentOnPostsMiddleware>();
}
}
}
HomeController.cs
The idea is to make a get to this endpoint so that your client code can retrieve the antiforgery token.
using Microsoft.AspNetCore.Mvc;
namespace SpaAntiforgery.Controllers
{
[Route("[controller]")]
[ApiController]
public class HomeController: ControllerBase
{
public IActionResult Index()
{
return Ok();
}
}
}
I also included a controller to test out a post.
using Microsoft.AspNetCore.Mvc;
namespace SpaAntiforgery.Controllers
{
[Route("[controller]")]
[ApiController]
public class TestAntiforgeryController : ControllerBase
{
[HttpPost]
public IActionResult Index()
{
return Ok();
}
}
}
Sending a post request to /testantiforgery using something like Postman results in an error because the post does not include the antiforgery token. This is what we want.
In order to test that a successful post can be made I created another website with the following code. Note the getCookie method comes straight from the Microsoft docs that I linked to at the start of my answer.
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title></title>
</head>
<body>
<button id="MyButton">
Test
</button>
<script>
const getCookie = cookieName => {
var name = cookieName + "=";
var decodedCookie = decodeURIComponent(document.cookie);
var ca = decodedCookie.split(";");
for (var i = 0; i < ca.length; i++) {
var c = ca[i];
while (c.charAt(0) == " ") {
c = c.substring(1);
}
if (c.indexOf(name) == 0) {
return c.substring(name.length, c.length);
}
}
return "";
};
const getCsrfToken = () => {
return getCookie("CSRF-TOKEN");
};
const getHeadersIncludingCsrfToken = () => {
const defaultHeaders = {
Accept: "application/json",
"Content-Type": "application/json"
};
return { ...defaultHeaders, "X-CSRF-TOKEN": getCsrfToken()};
};
const sendRequest = async (url, settings, done) => {
const baseUrl = "https://localhost:44333";
const response = await fetch(baseUrl + url, settings);
if (response.status !== 200) {
console.log("there was an api error");
return;
}
done();
};
const sendGet = async (url, done) => {
const settings = {
method: "GET"
};
await sendRequest(url, settings, done);
};
const sendPost = async (url, done) => {
const settings = {
method: "POST",
headers: getHeadersIncludingCsrfToken()
};
settings.credentials = "include";
await sendRequest(url, settings, done);
};
const sendAPost = () => {
sendPost("/testantiforgery", () => console.log("post succeeded!"));
}
const onTest = () => {
//sending a get to / means the antiforgery cookie is sent back
sendGet("/", sendAPost);
};
const MyButton = document.getElementById("MyButton");
MyButton.addEventListener("click", onTest);
</script>
</body>
</html>
As you can see from the javascript code, after clicking the button, the code sends a GET, this is just to retreive the antiforgery token. The GET is followed by a post. The CSRF-TOKEN is retreived from the cookies and included in the request headers. Note if trying this code out for yourself you will need to set your own baseUrl in the javascript code and also set your own url in the UseCors method in the Configure of Startup.

Set a custom SessionStore for ConfigureApplicationCookie without BuildServiceProvider()

I have a .NET Core 3 project (recently upgraded from 2.2) that uses a Redis distributed cache and cookie authentication.
It currently looks something like this:
public void ConfigureServices(IServiceCollection services)
{
// Set up Redis distributed cache
services.AddStackExchangeRedisCache(...);
...
services.ConfigureApplicationCookie(options =>
{
...
// Get a service provider to get the distributed cache set up above
var cache = services.BuildServiceProvider().GetService<IDistributedCache>();
options.SessionStore = new MyCustomStore(cache, ...);
}):
}
The problem is that BuildServiceProvider() causes a build error:
Startup.cs(...): warning ASP0000: Calling 'BuildServiceProvider' from application code results in an additional copy of singleton services being created. Consider alternatives such as dependency injecting services as parameters to 'Configure'.
This doesn't appear to be an option - ConfigureApplicationCookie is in Startup.ConfigureServices and can only configure new services, Startup.Configure can use the new services, but can't override CookieAuthenticationOptions.SessionStore to be my custom store.
I've tried adding services.AddSingleton<ITicketStore>(p => new MyCustomRedisStore(cache, ...)) before ConfigureApplicationCookie, but this is ignored.
Explicitly setting CookieAuthenticationOptions.SessionStore appears to be the only way to get it to use anything other than the local memory store.
Every example I've found online uses BuildServiceProvider();
Ideally I want to do something like:
services.ConfigureApplicationCookieStore(provider =>
{
var cache = provider.GetService<IDistributedCache>();
return new MyCustomStore(cache, ...);
});
Or
public void Configure(IApplicationBuilder app, ... IDistributedCache cache)
{
app.UseApplicationCookieStore(new MyCustomStore(cache, ...));
}
And then CookieAuthenticationOptions.SessionStore should just use whatever I've configured there.
How do I make the application cookie use an injected store?
Reference Use DI services to configure options
If all the dependencies of your custom store are injectable, then just register your store and required dependencies with the service collection and use DI services to configure options
public void ConfigureServices(IServiceCollection services) {
// Set up Redis distributed cache
services.AddStackExchangeRedisCache(...);
//register my custom store
services.AddSingleton<ITicketStore, MyCustomRedisStore>();
//...
//Use DI services to configure options
services.AddOptions<CookieAuthenticationOptions>(IdentityConstants.ApplicationScheme)
.Configure<ITicketStore>((options, store) => {
options.SessionStore = store;
});
services.ConfigureApplicationCookie(options => {
//do nothing
}):
}
If not then work around what is actually registered
For example
//Use DI services to configure options
services.AddOptions<CookieAuthenticationOptions>(IdentityConstants.ApplicationScheme)
.Configure<IDistributedCache>((options, cache) => {
options.SessionStore = new MyCustomRedisStore(cache, ...);
});
Note:
ConfigureApplicationCookie uses a named options instance. - #KirkLarkin
public static IServiceCollection ConfigureApplicationCookie(this IServiceCollection services, Action<CookieAuthenticationOptions> configure)
=> services.Configure(IdentityConstants.ApplicationScheme, configure);
The option would need to include the name when adding it to services.
To implement Redis Tickets in .NET Core 3.0 we did the following which is the above in a bit more of a final form::
services.AddSingleton<ITicketStore, RedisTicketStore>();
services.AddOptions<CookieAuthenticationOptions>(CookieAuthenticationDefaults.AuthenticationScheme)
.Configure<ITicketStore>((options, store) => {
options.SessionStore = store;
});
services.AddAuthentication(IdentityServerAuthenticationDefaults.AuthenticationScheme)
.AddIdentityServerAuthentication(options =>
{
// ...configure identity server options
}).AddCookie(CookieAuthenticationDefaults.AuthenticationScheme);
Here is a Redis implementation:
public class RedisTicketStore : ITicketStore
{
private const string KeyPrefix = "AuthSessionStore-";
private IDistributedCache cache;
public RedisTicketStore(IDistributedCache cache)
{
this.cache = cache;
}
public async Task<string> StoreAsync(AuthenticationTicket ticket)
{
var guid = Guid.NewGuid();
var key = KeyPrefix + guid.ToString();
await RenewAsync(key, ticket);
return key;
}
public Task RenewAsync(string key, AuthenticationTicket ticket)
{
var options = new DistributedCacheEntryOptions();
var expiresUtc = ticket.Properties.ExpiresUtc;
if (expiresUtc.HasValue)
{
options.SetAbsoluteExpiration(expiresUtc.Value);
}
byte[] val = SerializeToBytes(ticket);
cache.Set(key, val, options);
return Task.FromResult(0);
}
public Task<AuthenticationTicket> RetrieveAsync(string key)
{
AuthenticationTicket ticket;
byte[] bytes = null;
bytes = cache.Get(key);
ticket = DeserializeFromBytes(bytes);
return Task.FromResult(ticket);
}
public Task RemoveAsync(string key)
{
cache.Remove(key);
return Task.FromResult(0);
}
private static byte[] SerializeToBytes(AuthenticationTicket source)
{
return TicketSerializer.Default.Serialize(source);
}
private static AuthenticationTicket DeserializeFromBytes(byte[] source)
{
return source == null ? null : TicketSerializer.Default.Deserialize(source);
}
}
Redis implementation from: https://mikerussellnz.github.io/.NET-Core-Auth-Ticket-Redis/

Cannot replace default JSON contract resolver in ASP.NET Core 3

After creating basic Web API project based on .NET Core 3.0 framework, all API responses were coming in camel case. I installed SwashBuckle Swagger + built-in JSON serializer from System.Text.Json, specifically, to display enums as strings, everything worked as before. Then, I decided to switch to NSwag + NewtonSoftJson, because of some limitations of built-in serializer with dynamic and expando objects. Now, all API responses are displayed in PascalCase and I cannot change neither naming policy, nor even create custom contract resolver.
Example
https://forums.asp.net/t/2138758.aspx?Configure+SerializerSettings+ContractResolver
Question
I suspect that maybe some package overrides contract resolver behind the scene. How to make sure that API service uses ONLY custom contract resolver that I assign at startup and ignores all other similar settings?
Custom JSON contract resolver:
public class CustomContractResolver : IContractResolver
{
private readonly IHttpContextAccessor _context;
private readonly IContractResolver _contract;
private readonly IContractResolver _camelCaseContract;
public CustomContractResolver(IHttpContextAccessor context)
{
_context = context;
_contract = new DefaultContractResolver();
_camelCaseContract = new CamelCasePropertyNamesContractResolver();
}
// When API endpoint is hit, this method is NOT triggered
public JsonContract ResolveContract(Type value)
{
return _camelCaseContract.ResolveContract(value);
}
}
Controller:
[ApiController]
public class RecordsController : ControllerBase
{
[HttpGet]
[Route("services/records")]
[ProducesResponseType(typeof(ResponseModel<RecordEntity>), 200)]
public async Task<IActionResult> Records([FromQuery] QueryModel queryModel)
{
var response = new ResponseModel<RecordEntity>();
return Content(JsonConvert.SerializeObject(response), "application/json"); // NewtonSoft serializer
}
}
Startup.cs
public void ConfigureServices(IServiceCollection services)
{
services
.AddCors(o => o.AddDefaultPolicy(builder => builder
.AllowAnyOrigin()
.AllowAnyHeader()
.AllowAnyMethod()));
services
.AddControllers(o => o.RespectBrowserAcceptHeader = true)
/*
.AddJsonOptions(o =>
{
o.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter());
o.JsonSerializerOptions.DictionaryKeyPolicy = JsonNamingPolicy.CamelCase;
o.JsonSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase;
})
*/
.AddNewtonsoftJson(o =>
{
o.UseCamelCasing(true);
o.SerializerSettings.Converters.Add(new StringEnumConverter());
//o.SerializerSettings.ContractResolver = new CamelCasePropertyNamesContractResolver { NamingStrategy = new CamelCaseNamingStrategy() };
o.SerializerSettings.ContractResolver = new CustomContractResolver(new HttpContextAccessor());
});
services.AddOpenApiDocument(o => // NSwag
{
o.PostProcess = document =>
{
document.Info.Version = "v1";
document.Info.Title = "Demo API";
};
});
DataConnection.DefaultSettings = new ConnectionManager(DatabaseOptionManager.Instance); // LINQ to DB
}
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
if (env.IsDevelopment())
{
app.UseDeveloperExceptionPage();
}
app.UseCors(o => o.AllowAnyOrigin().AllowAnyMethod().AllowAnyHeader());
app.UseRouting();
app.UseAuthorization();
app.UseEndpoints(o => o.MapControllers());
app.UseOpenApi(); // NSwag
app.UseSwaggerUi3(o => o.Path = "/v2/docs");
app.UseReDoc(o => o.Path = "/v1/docs");
}
Still don't understand why custom contract resolver is not triggered by API endpoint, but found a combination that works for me to switch API to camel case. Feel free to explain why it works this way.
services.AddControllers(o => o.RespectBrowserAcceptHeader = true)
// Options for System.Text.Json don't affect anything, can be uncommented or removed
//.AddJsonOptions(o =>
//{
// o.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter());
// o.JsonSerializerOptions.DictionaryKeyPolicy = JsonNamingPolicy.CamelCase;
// o.JsonSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase;
//})
.AddNewtonsoftJson(o =>
{
o.UseCamelCasing(true);
o.SerializerSettings.Converters.Add(new StringEnumConverter());
// This option below breaks global settings, so had to comment it
//o.SerializerSettings.ContractResolver = new CamelCasePropertyNamesContractResolver
//{
// NamingStrategy = new CamelCaseNamingStrategy()
//};
});
JsonConvert.DefaultSettings = () => new JsonSerializerSettings
{
ContractResolver = new CamelCasePropertyNamesContractResolver()
};
Idea was taken from this article.
NewtonSoft allows to set global serialization settings disregarding MVC, Web API, and other frameworks.

How to force re authentication between ASP Net Core 2.0 MVC web app and Azure AD

I have an ASP.Net Core MVC web application which uses Azure AD for authentication. I have just received a new requirement to force user to reauthenticate before entering some sensitive information (the button to enter this new information calls a controller action that initialises a new view model and returns a partial view into a bootstrap modal).
I have followed this article which provides a great guide for achieving this very requirement. I had to make some tweaks to get it to work with ASP.Net Core 2.0 which I think is right however my problems are as follows...
Adding the resource filter decoration "[RequireReauthentication(0)]" to my controller action works however passing the value 0 means the code never reaches the await.next() command inside the filter. If i change the parameter value to say 30 it works but seems very arbitrary. What should this value be?
The reauthentication works when calling a controller action that returns a full view. However when I call the action from an ajax request which returns a partial into a bootstrap modal it fails before loading the modal with
Response to preflight request doesn't pass access control check: No
'Access-Control-Allow-Origin' header is present on the requested
resource. Origin 'https://localhost:44308' is therefore not allowed
access
This looks like a CORS issue but I don't know why it would work when going through the standard mvc process and not when being called from jquery. Adding
services.AddCors();
app.UseCors(builder =>
builder.WithOrigins("https://login.microsoftonline.com"));
to my startup file doesn't make any difference. What could be the issue here?
Startup.cs
public void ConfigureServices(IServiceCollection services)
{
// Ommitted for clarity...
services.AddAuthentication(sharedOptions =>
{
sharedOptions.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
sharedOptions.DefaultChallengeScheme = OpenIdConnectDefaults.AuthenticationScheme;
})
.AddAzureAd(options => Configuration.Bind("AzureAd", options))
.AddCookie();
services.AddCors();
// Ommitted for clarity...
}
public void Configure(IApplicationBuilder app, IHostingEnvironment env)
{
// Ommitted for clarity...
app.UseCors(builder => builder.WithOrigins("https://login.microsoftonline.com"));
app.UseStaticFiles();
app.UseAuthentication();
app.UseMvc(routes =>
{
routes.MapRoute(
name: "default",
template: "{controller=Home}/{action=Index}/{id?}");
});
}
AzureAdAuthenticationBuilderExtensions.cs
public static class AzureAdAuthenticationBuilderExtensions
{
public static AuthenticationBuilder AddAzureAd(this AuthenticationBuilder builder)
=> builder.AddAzureAd(_ => { });
public static AuthenticationBuilder AddAzureAd(this AuthenticationBuilder builder, Action<AzureAdOptions> configureOptions)
{
builder.Services.Configure(configureOptions);
builder.Services.AddSingleton<IConfigureOptions<OpenIdConnectOptions>, ConfigureAzureOptions>();
builder.AddOpenIdConnect(options =>
{
options.ClaimActions.Remove("auth_time");
options.Events = new OpenIdConnectEvents
{
OnRedirectToIdentityProvider = RedirectToIdentityProvider
};
});
return builder;
}
private static Task RedirectToIdentityProvider(RedirectContext context)
{
// Force reauthentication for sensitive data if required
if (context.ShouldReauthenticate())
{
context.ProtocolMessage.MaxAge = "0"; // <time since last authentication or 0>;
}
else
{
context.Properties.RedirectUri = new PathString("/Account/SignedIn");
}
return Task.FromResult(0);
}
internal static bool ShouldReauthenticate(this RedirectContext context)
{
context.Properties.Items.TryGetValue("reauthenticate", out string reauthenticate);
bool shouldReauthenticate = false;
if (reauthenticate != null && !bool.TryParse(reauthenticate, out shouldReauthenticate))
{
throw new InvalidOperationException($"'{reauthenticate}' is an invalid boolean value");
}
return shouldReauthenticate;
}
// Ommitted for clarity...
}
RequireReauthenticationAttribute.cs
public class RequireReauthenticationAttribute : Attribute, IAsyncResourceFilter
{
private int _timeElapsedSinceLast;
public RequireReauthenticationAttribute(int timeElapsedSinceLast)
{
_timeElapsedSinceLast = timeElapsedSinceLast;
}
public async Task OnResourceExecutionAsync(ResourceExecutingContext context, ResourceExecutionDelegate next)
{
var foundAuthTime = int.TryParse(context.HttpContext.User.FindFirst("auth_time")?.Value, out int authTime);
var ts = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
if (foundAuthTime && ts - authTime < _timeElapsedSinceLast)
{
await next();
}
else
{
var state = new Dictionary<string, string> { { "reauthenticate", "true" } };
await AuthenticationHttpContextExtensions.ChallengeAsync(context.HttpContext, OpenIdConnectDefaults.AuthenticationScheme, new AuthenticationProperties(state));
}
}
}
CreateNote.cs
[HttpGet]
[RequireReauthentication(0)]
public IActionResult CreateNote(int id)
{
TempData["IsCreate"] = true;
ViewData["PostAction"] = "CreateNote";
ViewData["PostRouteId"] = id;
var model = new NoteViewModel
{
ClientId = id
};
return PartialView("_Note", model);
}
Razor View (snippet)
<a asp-controller="Client" asp-action="CreateNote" asp-route-id="#ViewData["ClientId"]" id="client-note-get" data-ajax="true" data-ajax-method="get" data-ajax-update="#client-note-modal-content" data-ajax-mode="replace" data-ajax-success="ShowModal('#client-note-modal', null, null);" data-ajax-failure="AjaxFailure(xhr, status, error, false);"></a>
All help appreciated. Thanks
The CORS problem is not in your app.
Your AJAX call is trying to follow the authentication redirect to Azure AD,
which will not work.
What you can do instead is in your RedirectToIdentityProvider function, check if the request is an AJAX request.
If it is, make it return a 401 status code, no redirect.
Then your client-side JS needs to detect the status code, and issue a redirect that triggers the authentication.