How to force re authentication between ASP Net Core 2.0 MVC web app and Azure AD - asp.net-core

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.

Related

How to return HTTP 404

I am building an asp.net core Web API and I need to be able to hide some of the actions in a controller.
I use the following code to return HTTP 404 (Not Found):
[HttpGet]
public IActionResult Index()
{
if(!_isEnabled)
{
return NotFound();
}
However, in my browser I get this result:
{
"type": "https://tools.ietf.org/html/rfc7231#section-6.5.4",
"title": "Not Found",
"status": 404,
"traceId": "00-502319d62a6027718d2ee2cb3c9f263f-28de7bfdfb48f2d8-00"
}
I need to make the call as if the controller does not exists and the browser shows this:
How can a Controller returns a "real" HTTP 404 experience as if the controller dos not exists at that route?
Update 1
The answers return a JSON data and response code 404.
I am trying to do something different.
I am trying to hide the controller as if it doesn't exist for security reasons. I like the end user browser see above screenshot (Edge in my example)
Update 2
I changed to the following code:
[HttpGet]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status200OK)]
public IActionResult Index()
{
if(!_isEnabled)
{
return StatusCode(StatusCodes.Status404NotFound);
}
and the controller returns the following result:
{"type":"https://tools.ietf.org/html/rfc7231#section-6.5.4","title":"Not Found","status":404,"traceId":"00-3275026575270e11a4b1a5ab0817776a-a4777e626460faeb-00"}
The behavior is strange. Is it a new feature in aspnet code 6 ?
Update 3
Here is my middleware setup in the Program.c. It is plain oob setup:
public static void Main(string[] args)
{
var builder = WebApplication.CreateBuilder(args);
builder.Logging.ClearProviders();
builder.Logging.AddConsole();
builder.Logging.AddAzureWebAppDiagnostics();
// Add services to the container.
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddMicrosoftIdentityWebApi(builder.Configuration.GetSection("AzureAd"));
builder.Services.AddControllers();
// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
builder.Services.AddApplicationInsightsTelemetry();
var app = builder.Build();
// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}
app.UseHttpsRedirection();
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
app.Run();
}
Solution For Update 1:
Middleware could be your savior here thus can be achived what you are trying to implement.
Controller:
[ProducesResponseType(StatusCodes.Status404NotFound)]
public IActionResult GetById(int id)
{
// return Ok(NotFound());
return StatusCode(StatusCodes.Status404NotFound);
}
Note: You can choose either of the status pattern.
Middleware:
public class CustomResponseMiddleware
{
private readonly RequestDelegate _next;
public CustomResponseMiddleware(RequestDelegate next)
{
_next = next;
}
public async Task InvokeAsync(HttpContext httpContext)
{
if (httpContext.Response.StatusCode == 404)
{
httpContext.Response.Redirect("/WrongControllerName/WrongAction");
}
await _next(httpContext);
}
}
Note: As you can see, we are checking the controller status code and checking if any 404 decteced. Once the desired status code we will redirect a controller which doesn't exist at all that eventually generate the expected output.
Register Middleware In Program.cs:
app.UseMiddleware<CustomResponseMiddleware>();
Output:

How to use fallback routing in asp.net core?

I am using asp.net web-api with controllers.
I want to do a user section where one can request the site's address with the username after it like example.com/username. The other, registered routes like about, support, etc. should have a higher priority, so if you enter example.com/about, the about page should go first and if no such about page exists, it checks whether a user with such name exists. I only have found a way for SPA fallback routing, however I do not use a SPA. Got it working manually in a middleware, however it is very complicated to change it.
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
string[] internalRoutes = new string[] { "", "about", "support", "support/new-request", "login", "register" };
string[] userNames = new string[] { "username1", "username2", "username3" };
app.Use(async (context, next) =>
{
string path = context.Request.Path.ToString();
path = path.Remove(0, 1);
path = path.EndsWith("/") ? path[0..^1] : path;
foreach (string route in internalRoutes)
{
if (route == path)
{
await context.Response.WriteAsync($"Requested internal page '{path}'.");
return;
}
}
foreach (string userName in userNames)
{
if (userName == path)
{
await context.Response.WriteAsync($"Requested user profile '{path}'.");
return;
}
}
await context.Response.WriteAsync($"Requested unknown page '{path}'.");
return;
await next(context);
});
app.Run();
It's really straightforward with controllers and attribute routing.
First, add controller support with app.MapControllers(); (before app.Run()).
Then, declare your controller(s) with the appropriate routing. For simplicity, I added a single one that just returns simple strings.
public class MyController : ControllerBase
{
[HttpGet("/about")]
public IActionResult About()
{
return Ok("About");
}
[HttpGet("/support")]
public IActionResult Support()
{
return Ok("Support");
}
[HttpGet("/support/new-request")]
public IActionResult SupportNewRequest()
{
return Ok("New request support");
}
[HttpGet("/{username}")]
public IActionResult About([FromRoute] string username)
{
return Ok($"Hello, {username}");
}
}
The routing table will first check if there's an exact match (e.g. for /about or /support), and if not, if will try to find a route with matching parameters (e.g. /Métoule will match the /{username} route).

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.

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.

Is ASP.NET Core Identity needed for Intranet app using Windows Authentication

Using Windows Authentication in an Intranet web application I want to achieve the following:
Gather additional attributes from AD (name, employee number)
Gather additional attributes from a database table (working hours, pay)
Authorize based on application roles (not AD groups)
Authorize based on an AD attribute (has direct reports)
User not provide a username/password
In my search for an answer it is suggested that I need to add ClaimsTransformation to my application:
How do I use Windows Authentication with users in database
Populate custom claim from SQL with Windows Authenticated app in .Net Core
Caching Claims in .net core 2.0
Though I don't fully understand the solution and why ClaimsTransformation happens on every request so I'm looking for answers to the following:
Is ASP.NET Core Identity required for ClaimsTransformation to work?
Does ClaimsTransformation happen on every request with just Windows Authentication or also with form based authentication?
Does this have to happen on every request?
Caching claims like GivenName, Surname seem simple but what about roles? What steps need to be taken to ensure the database isn't hit every time but roles do get updated when there are changes.
Is there a simpler alternative for what I'm trying to do?
This article gave me some ideas and here is a possible solution.
Controllers would inherit from a base controller which has a policy that requires the Authenticated claim. When this isn't present it goes to the AccessDeniedPath and silently performs the login adding the Authenticated claim along with any other claims, if this is already present then the Access Denied message would appear.
When creating the new ClaimsIdentity I've had to strip most of the Claims in the original identity as I was getting a HTTP 400 - Bad Request (Request Header too long) error message.
Are there any obvious issues with this approach?
Startup.cs
public class Startup
{
public Startup(IConfiguration configuration)
{
Configuration = configuration;
}
public IConfiguration Configuration { get; }
public void ConfigureServices(IServiceCollection services)
{
services.AddMvc();
services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
.AddCookie(options =>
{
options.LoginPath = "/Home/Login";
options.AccessDeniedPath = "/Home/AccessDenied";
});
services.AddAuthorization(options =>
{
options.AddPolicy("Authenticated",
policy => policy.RequireClaim("Authenticated"));
options.AddPolicy("Admin",
policy => policy.RequireClaim("Admin"));
});
}
public void Configure(IApplicationBuilder app, IHostingEnvironment env)
{
if (env.IsDevelopment())
{
app.UseBrowserLink();
app.UseDeveloperExceptionPage();
}
else
{
app.UseExceptionHandler("/Home/Error");
}
app.UseStaticFiles();
app.UseAuthentication();
app.UseMvc(routes =>
{
routes.MapRoute(
name: "default",
template: "{controller=Home}/{action=Index}/{id?}");
});
}
}
Controller
[Authorize(Policy = "Authenticated")]
public class HomeController : Controller
{
public IActionResult Index()
{
return View();
}
[Authorize(Policy = "Admin")]
public IActionResult About()
{
return View();
}
[AllowAnonymous]
public async Task<IActionResult> Login(string returnUrl)
{
var identity = ((ClaimsIdentity)HttpContext.User.Identity);
var claims = new List<Claim>
{
new Claim("Authenticated", "True"),
new Claim(ClaimTypes.Name,
identity.FindFirst(c => c.Type == ClaimTypes.Name).Value),
new Claim(ClaimTypes.PrimarySid,
identity.FindFirst(c => c.Type == ClaimTypes.PrimarySid).Value)
};
var claimsIdentity = new ClaimsIdentity(
claims,
identity.AuthenticationType,
identity.NameClaimType,
identity.RoleClaimType);
await HttpContext.SignInAsync(
CookieAuthenticationDefaults.AuthenticationScheme,
new ClaimsPrincipal(claimsIdentity),
new AuthenticationProperties());
return Redirect(returnUrl);
}
[AllowAnonymous]
public IActionResult AccessDenied(string returnUrl)
{
if (User.FindFirst("Authenticated") == null)
return RedirectToAction("Login", new { returnUrl });
return View();
}
}
Here is an alternative which does use IClaimsTransformation (using .NET 6)
A few notes:
In the ClaimsTransformer class it's essential to clone the existing ClaimsPrincipal and add your Claims to that, rather than trying to modify the existing one. It must then be registered as a singleton in ConfigureServices().
The technique used in mheptinstall's answer to set the AccessDeniedPath won't work here, instead I had to use the UseStatusCodePages() method in order to redirect to a custom page for 403 errors.
The new claim must be created with type newIdentity.RoleClaimType, NOT System.Security.Claims.ClaimTypes.Role, otherwise the AuthorizeAttribute (e.g. [Authorize(Roles = "Admin")]) will not work
Obviously the application will be set up to use Windows Authentication.
ClaimsTransformer.cs
public class ClaimsTransformer : IClaimsTransformation
{
// Can consume services from DI as needed, including scoped DbContexts
public ClaimsTransformer(IHttpContextAccessor httpAccessor) { }
public async Task<ClaimsPrincipal> TransformAsync(ClaimsPrincipal principal)
{
// Clone current identity
var clone = principal.Clone();
var newIdentity = (ClaimsIdentity)clone.Identity;
// Get the username
var username = principal.Claims.FirstOrDefault(c => c.Type == ClaimTypes.NameIdentifier || c.Type == ClaimTypes.Name).Value;
if (username == null)
{
return principal;
}
// Get the user roles from the database using the username we've just obtained
// Ideally these would be cached where possible
// ...
// Add role claims to cloned identity
foreach (var roleName in roleNamesFromDatabase)
{
var claim = new Claim(newIdentity.RoleClaimType, roleName);
newIdentity.AddClaim(claim);
}
return clone;
}
}
Startup.cs
public void ConfigureServices(IServiceCollection services)
{
services.AddAuthentication(IISDefaults.AuthenticationScheme);
services.AddAuthorization();
services.AddSingleton<IClaimsTransformation, ClaimsTransformer>();
services.AddMvc().AddRazorRuntimeCompilation();
// ...
}
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
app.UseStatusCodePages(async context => {
if (context.HttpContext.Response.StatusCode == 403)
{
context.HttpContext.Response.Redirect("/Home/AccessDenied");
}
});
app.UseHttpsRedirection();
app.UseStaticFiles();
app.UseRouting();
app.UseAuthentication();
app.UseAuthorization();
app.UseEndpoints(endpoints =>
{
endpoints.MapControllerRoute(
name: "default",
pattern: "{controller=Home}/{action=Index}/{id?}");
});
}
Example HomeController.cs
[Authorize]
public class HomeController : Controller
{
public HomeController()
{ }
public IActionResult Index()
{
return View();
}
[Authorize(Roles = "Admin")]
public IActionResult AdminOnly()
{
return View();
}
[AllowAnonymous]
public IActionResult AccessDenied()
{
return View();
}
}