Authentication for .NET Core Razor Pages application doesn't work for views without an "/Identity" route while using .AddIdentityServerJwt() - asp.net-core

Using the .NET Core 3.1 framework, I'm trying to configure a web platform with the following setup:
A Razor Pages application, that acts as the landing page for the platform with features/pages such as advertising the platform, cookie consent, privacy policy, contacts, and the pages that come with Identity (e.g., login, register, manage account).
Authentication for the Razor Pages application is performed in the standard Identity way.
An Angular SPA, that is only accessible after the user is logged in.
OIDC configuration with Identity Server in order to add authentication and authorisation to the Angular SPA.
All of these three components (Razor Pages + Angular + Identity Server) are bundled into one single .NET Core web project. I have also scaffolded Identity so that I am able to customise the look and behaviour of the pages.
I was able to almost configure the application the way I want it, by basically mixing the code of the startup templates of the Razor Pages option (with user accounts stored locally) and the Angular template option (with user accounts stored locally) and with a bit of trial and error and investigation.
The current status of my application is:
The user logs in in the Razor Pages application.
The login is successful and the email is displayed on the navigation bar.
When we navigate to the SPA, my Angular app tries to silently login and is successful:
localhost:5001/Dashboard (Angular SPA home route)
If we navigate to a part of the Razor Pages application that does not have the /Identity route (which is only used for the pages that come with Identity) the cookies appear to no longer contain the right information and I have no session in those routes. This means that, for example, if I am using the SignInManager.IsSignedIn(User) to only display a navigation option to an Administration page that is protected with an options.Conventions.AuthorizePage($"/Administration"), if I am in a URL that has the Identity route, the navigation tab will be displayed, otherwise it will not be displayed:
localhost:5001/Identity/Account/Login
localhost:5001 (Razor Pages application home route)
However, even though the Administration navigation tab is being displayed when I am on a URL that has the /Identity route, if I click on it I will get a 401 unauthorised error, because the Administration page is not preceded by the /Identity route:
localhost:5001/Administration
I have managed to trace the problem to the the AddIdentityServerJwt(). Without this, the login for the Razor Pages application works as intended, but I am obviously unable to use authentication and authorisation with the Angular application afterwards.
I went to check the source code for that method and it turns out that it creates a new IdentityServerJwtPolicySchemeForwardSelector that forwards the JWT policy scheme to the DefaultIdentityUIPathPrefix which, as you might have guessed it, contains only the value "/Identity".
I have configured my Startup class in the following way:
Startup.cs
public void ConfigureServices(IServiceCollection services)
{
JwtSecurityTokenHandler.DefaultInboundClaimTypeMap.Clear();
services.Configure<CookiePolicyOptions>(options =>
{
options.CheckConsentNeeded = context => true;
options.MinimumSameSitePolicy = SameSiteMode.None;
});
services
.AddDbContext<ApplicationDbContext>(optionsBuilder =>
{
DatabaseProviderFactory
.CreateDatabaseProvider(configuration, optionsBuilder);
});
services
.AddDefaultIdentity<IdentityUser>(options => options.SignIn.RequireConfirmedAccount = true)
.AddRoles<IdentityRole>()
.AddEntityFrameworkStores<ApplicationDbContext>();
services
.AddIdentityServer()
.AddApiAuthorization<IdentityUser, ApplicationDbContext>();
services
.AddAuthentication()
.AddIdentityServerJwt();
services
.AddControllersWithViews();
services
.AddRazorPages()
.AddRazorPagesOptions(options =>
{
options.Conventions.AuthorizePage($"/Administration");
});
services
.AddSpaStaticFiles(configuration =>
{
configuration.RootPath = "ClientApp/dist";
});
services.AddTransient<IEmailSender, EmailSenderService>();
services.Configure<AuthMessageSenderOptions>(configuration);
services.AddTransient<IProfileService, ProfileService>();
}
public void Configure(IApplicationBuilder applicationBuilder, IWebHostEnvironment webHostEnvironment)
{
SeedData.SeedDatabase(applicationBuilder, configuration);
if (webHostEnvironment.IsDevelopment())
{
applicationBuilder.UseDeveloperExceptionPage();
applicationBuilder.UseDatabaseErrorPage();
}
else
{
applicationBuilder.UseExceptionHandler("/Error");
applicationBuilder.UseHsts();
}
applicationBuilder.UseHttpsRedirection();
applicationBuilder.UseStaticFiles();
applicationBuilder.UseCookiePolicy();
if (!webHostEnvironment.IsDevelopment())
{
applicationBuilder.UseSpaStaticFiles();
}
applicationBuilder.UseRouting();
applicationBuilder.UseAuthentication();
applicationBuilder.UseIdentityServer();
applicationBuilder.UseAuthorization();
applicationBuilder.UseEndpoints(endpoints =>
{
endpoints.MapControllerRoute(
name: "default",
pattern: "{controller}/{action=Index}/{id?}");
endpoints.MapRazorPages();
});
applicationBuilder.UseSpa(spa =>
{
spa.Options.SourcePath = "ClientApp";
if (webHostEnvironment.IsDevelopment())
{
if (bool.Parse(configuration["DevelopmentConfigurations:UseProxyToSpaDevelopmentServer"]))
{
spa.UseProxyToSpaDevelopmentServer(configuration["DevelopmentConfigurations:ProxyToSpaDevelopmentServerAddress"]);
}
else
{
spa.UseAngularCliServer(npmScript: configuration["DevelopmentConfigurations:AngularCliServerNpmScript"]);
}
}
});
}
How can I configure my application so that the session is available across my entire application and not just on URLs that have the "/Identity" route while maintaining both authentication and authorisation for the Razor Pages application and the Angular application?

I had the same problem and solved it by adding my own PolicyScheme that decides which type of authentication should be used based on the request path. All my razor pages have a path starting with "/Identity" or "/Server" and all other requests should use JWT.
I set this up in ConfigureServices using the collowing coding:
// Add authentication using JWT and add a policy scheme to decide which type of authentication should be used
services.AddAuthentication()
.AddIdentityServerJwt()
.AddPolicyScheme("ApplicationDefinedAuthentication", null, options =>
{
options.ForwardDefaultSelector = (context) =>
{
if (context.Request.Path.StartsWithSegments(new PathString("/Identity"), StringComparison.OrdinalIgnoreCase) ||
context.Request.Path.StartsWithSegments(new PathString("/Server"), StringComparison.OrdinalIgnoreCase))
return IdentityConstants.ApplicationScheme;
else
return IdentityServerJwtConstants.IdentityServerJwtBearerScheme;
};
});
// Use own policy scheme instead of default policy scheme that was set in method AddIdentityServerJwt
services.Configure<AuthenticationOptions>(options => options.DefaultScheme = "ApplicationDefinedAuthentication");

Related

How to configure Azure AD authentication in Hybrid ASP.NET Core MVC (backend) and Vuejs SPA (frontend)?

My application is a hybrid approach where use ASP.NET Core MVC as my backend. I have various controllers which my front end uses to pull data from our database and also to do API calls on MS Graph. I am using the following program.cs file to get the authentication initiated when a user first logs on to the site:
//authentication pipline
builder.Services.AddHttpContextAccessor();
var initialScopes = builder.Configuration.GetValue<string>("DownstreamApi:Scopes")?.Split(' ');
builder.Services.AddAuthentication(OpenIdConnectDefaults.AuthenticationScheme)
.AddMicrosoftIdentityWebApp(options =>
{
builder.Configuration.Bind("AzureAd", options);
options.Events = new OpenIdConnectEvents
{
//Tap into this event to add a UserID Claim to a new HttpContext identity
OnTokenValidated = context =>
{
//This query returns the UserID from the DB by sending the email address in the claim from Azure AD
string query = "select dbo.A2F_0013_ReturnUserIDForEmail(#Email) as UserID";
string connectionString = builder.Configuration.GetValue<string>("ConnectionStrings:DBContext");
string signInEmailAddress = context.Principal.FindFirstValue("preferred_username");
using (var connection = new SqlConnection(connectionString))
{
var queryResult = connection.QueryFirst(query, new { Email = signInEmailAddress });
var claims = new List<Claim>
{
new Claim("UserID", queryResult.UserID.ToString())
};
var appIdentity = new ClaimsIdentity(claims);
context.Principal.AddIdentity(appIdentity);
}
return Task.CompletedTask;
},
};
}).EnableTokenAcquisitionToCallDownstreamApi(initialScopes)
.AddMicrosoftGraph(builder.Configuration.GetSection("DownstreamApi"))
.AddInMemoryTokenCaches();
//Add Transient Services
builder.Services.AddTransient<IOneDrive, OneDrive>();
builder.Services.AddControllers(options =>
{
var policy = new AuthorizationPolicyBuilder()
.RequireAuthenticatedUser()
.Build();
options.Filters.Add(new AuthorizeFilter(policy));
}).AddMicrosoftIdentityUI();
builder.Services.AddRazorPages().AddRazorPagesOptions(options =>
{
options.Conventions.AllowAnonymousToFolder("/Login");
options.Conventions.AuthorizeFolder("/");
options.Conventions.AuthorizeFolder("/files");
}).AddMicrosoftIdentityUI();
// Add the UI support to handle claims challenges
builder.Services.AddServerSideBlazor()
.AddMicrosoftIdentityConsentHandler();
builder.Services.AddRequiredScopeAuthorization();
In the Azure AD portal my application is registered as a web app. So when a user initially goes to the site they are redirected to https://login.microsoftonline.com/blahblah to get the login process started. This is automated by the Azure AD identity platform. Then once the login occurs they are redirected to localhost where the VueJS spa is loaded (localhost:43862). My spa uses various axios requests to the controllers and they pull data and vue router loads components. However, my issue is say the user needs to relog in because the cookie is expired or they logged out in another tab. The next axios request made by the expired session does not redirect the user to Azure login screen but instead results in an CORS error. So I need to get my axios requests to force the page redirect to Azure AD login screen (which probably is the worst idea since CORS policy is resulting in error) or have it return a redirect to localhost/login which is my own custom login screen with a button to Azure AD login and shouldnt impact CORS. So how do I intercept this Azure AD redirect to Azure AD login and replace with my own?
I have also tried to return a 401 error code so I could check for that in my axios request but to no avail it does nothing. If I put a breakpoint there it does hit this code but it does not change the status code of the response and I still get 302. My code for that was to try and add to the event :
OnRedirectToIdentityProvider = context =>
{
context.Response.StatusCode = 401;
return Task.CompletedTask;
}
My other ideas was maybe I should set my CORS policy to allow redirects from login.microsoft.com? Or would this be bad practice?
I can answer part of your question... First, for our API application which is protected by Azure AD, what the API should do is validating the request whether it contained a correct access token in the request header, if yes, give the response, if no, then give error like 401 or 403. A normal API application shouldn't have a UI to let users sign in. Anyway, if you want to expose an API in an MVC project, it's OK, but for API itself, it shouldn't have a UI.
Let's see sample below, I had a .net 6 web api project, and here's my program.cs:
using Microsoft.Identity.Web;
var builder = WebApplication.CreateBuilder(args);
// Add services to the container.
builder.Services.AddMicrosoftIdentityWebApiAuthentication(builder.Configuration);
builder.Services.AddControllers();
// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
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();
And it requires configurations in appsetting.json.
"AzureAd": {
"Instance": "https://login.microsoftonline.com/",
"ClientId": "azure_ad_client_id",
"ClientSecret": "client_secret",
"Domain": "tenant_id",
"TenantId": "tenant_id",
//"Audience": "api://azure_ad_client_id_which exposed_api" // here I used the same azure ad app to expose API, so I can comment this property
},
And this is the Controller:
[ApiController]
[Route("[controller]")]
[Authorize]
public class WeatherForecastController : ControllerBase
{
[RequiredScope("Tiny.Read")]
[HttpGet]
public string Get()
{
return "world";
}
}
I had an Azure AD app, and I exposed an API like this:
I also add this API for the same Azure AD app.
Then let's do a test. When I call this API directly, I will get 401 error:
If I used an expired token within the request, I will also get 401 error:
But if I used a correct token(go to https://jwt.io to decode the token, we should see it containing correct scope, for me its "scp": "Tiny.Read",), I will get response:
And till now, the API part had finished. Let's see the client SPA. For SPA, you should integrate MSAL so that you can make your users to sign in via Azure AD, and generate the access token for calling MS graph API or your own API. The code for generating access token should be the same but you should set different scope for different API. In my scenario, my API required a scope Tiny.Read, then I should set in my client App.
Here's an screenshot for generating access token in react. You need to set the scope in your code.
Now you have the method to generate access token, you already know the API url. Then you can send request to call api, using AJAX, using fetch, or something else, sending an http request is ok. And in the calling api part, you also need to handle the response. If the response code is 401, then you need to do some logic, maybe redirect to the sign in page. And you said you had trouble here, you met CORS issue. I can't answer this part. I think it depends on how you redirect to Azure AD sign in page. I'm afraid you can take a look at this sample to learn how to sign in users and call graph api.

Custom post Sign-in page and Sign-out page for Microsoft Identity platform

I would like to use Microsoft Identity platform in my ASP.NET Core.NET 6 application with a custom sign in and sign out page.
In my case, i don't want to redirect automatically user to Microsoft sign in page but to my custom sign in page (that contain login button).Same for signout page.
I am using Microsoft.Identity.Web and Microsoft.Identity.Web.UI
var builder = WebApplication.CreateBuilder(args);
// Add services to the container.
builder.Services.AddAuthentication(OpenIdConnectDefaults.AuthenticationScheme)
.AddMicrosoftIdentityWebApp(builder.Configuration.GetSection("AzureAd"));
builder.Services.AddAuthorization(options =>
{
// By default, all incoming requests will be authorized according to the default policy.
options.FallbackPolicy = options.DefaultPolicy;
});
builder.Services.AddRazorPages()
.AddMicrosoftIdentityUI();
var app = builder.Build();
how can I change the default behaviour ?
For example, I integrate azure ad in my asp.net core MVC project:
public void ConfigureServices(IServiceCollection services)
{
services.AddMicrosoftIdentityWebAppAuthentication(Configuration);
services.AddControllersWithViews()
// Add the Microsoft Identity UI pages for signin/out
.AddMicrosoftIdentityUI();
}
And I set the default home page to my HelloController, it will reder hello/index.cshtml to the page, with a button <a asp-action="index" asp-controller="home">Home</a>
And in my HomeController.cs, it has [Authorize] annotation on the controller, so when I didn't sign in with azure ad, it will redirect to microsoft sign in page. Then since I set the callback path as the home/index.cshtml, after sign in, I will go to home/index.cshtml.
My home controller:
using Microsoft.AspNetCore.Authorization;
[Authorize]
public class HomeController : Controller
{

How is access restricted in the ASP.NET Core web app template when using ASP.NET Core Identity

I have an ASP.NET Core 5 web app that's using ASP.NET Core Identity for authorization. I have scaffolded all of the Identity UI so that I can see how it works.
Within the scaffolded Razor pages in the "Identity" area, I can see several page model classes that are decorated with [AllowAnonymous], but I can't see any reference to anything that restricts access.
There must be something somewhere, though, because some pages in the template website are accessible when not signed in (even though they do not have [AllowAnonymous]), yet most pages in the scaffolded Identity area are not accessible unless signed-in.
How is this achieved? I expected to see a call to AuthorizeFolder (or AuthorizeAreaFolder) but I can't see one anywhere in the project.
I'd like to add some authorization rules of my own, but I'd like to know what the existing rules are before I start making changes.
To maintain full control of the Identity UI, run the Identity scaffolder and select Override all files.
You might want to do this to have full control of the Identity UI.
public void ConfigureServices(IServiceCollection services)
{
services.Configure<CookiePolicyOptions>(options =>
{
options.CheckConsentNeeded = context => true;
options.MinimumSameSitePolicy = SameSiteMode.None;
});
services.AddDbContext<ApplicationDbContext>(options =>
options.UseSqlServer(
Configuration.GetConnectionString("DefaultConnection")));
services.AddIdentity<IdentityUser, IdentityRole>()
// services.AddDefaultIdentity<IdentityUser>()
.AddEntityFrameworkStores<ApplicationDbContext>()
.AddDefaultTokenProviders();
services.AddMvc()
.AddRazorPagesOptions(options =>
{
options.Conventions.AuthorizeAreaFolder("Identity", "/Account/Manage");
options.Conventions.AuthorizeAreaPage("Identity", "/Account/Logout");
});
services.ConfigureApplicationCookie(options =>
{
options.LoginPath = $"/Identity/Account/Login";
options.LogoutPath = $"/Identity/Account/Logout";
options.AccessDeniedPath = $"/Identity/Account/AccessDenied";
});
// using Microsoft.AspNetCore.Identity.UI.Services;
services.AddSingleton<IEmailSender, EmailSender>();
}
Reference: Create full Identity UI source
Simple authorization in ASP.NET Core
Razor Pages authorization conventions in ASP.NET Core

Asp.Net Core configure Identity authentication middleware properly

Requirement is that I have MVC & WebAPI in the project. MVC views will be delivered for initial
rendering like Login, base views of features (ex:- Users/Index, Dashboard/Index etc..) Web APIs will be used for other work within these views via AJAX with JWT.
I am using Asp.Net core Identity for user management related work running on .Net 5.0
I am confused with configuring multiple identity schemes and the proper configuration of authentication/authorization pipeline in conigureservices() & configure() in startup.
To configure multiple auth schemes I referred to https://stackoverflow.com/a/64887479/2058413 since it's done using same versions that I use. So my startup methods are below which is similar to the code in that thread.
public void ConfigureServices(IServiceCollection services)
{
string connectionString = Configuration.GetConnectionString("default");
services.AddDbContext<AppDBContext>(c => c.UseSqlServer(connectionString));
services.AddIdentity<IdentityUser, IdentityRole>()
.AddEntityFrameworkStores<AppDBContext>();
services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
.AddCookie(x =>
{
x.LoginPath = "/Account/Login";
x.ExpireTimeSpan = TimeSpan.FromMinutes(10d);
x.AccessDeniedPath = "/Account/Register";
})
.AddJwtBearer(x =>
{
x.RequireHttpsMetadata = false;
x.SaveToken = true;
x.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuerSigningKey = true,
IssuerSigningKey = new SymmetricSecurityKey(Encoding.ASCII.GetBytes("123456")),
ValidateIssuer = false,
ValidateAudience = false
};
});
services.AddAuthorization(options =>
{
var defaultAuthorizationPolicyBuilder = new AuthorizationPolicyBuilder(CookieAuthenticationDefaults.AuthenticationScheme, JwtBearerDefaults.AuthenticationScheme);
defaultAuthorizationPolicyBuilder = defaultAuthorizationPolicyBuilder.RequireAuthenticatedUser();
options.DefaultPolicy = defaultAuthorizationPolicyBuilder.Build();
});
services.AddControllersWithViews();
}
My App configure method is below
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
if (env.IsDevelopment())
{
app.UseDeveloperExceptionPage();
}
else
{
app.UseExceptionHandler("/Home/Error");
}
app.UseStaticFiles();
app.UseRouting();
app.UseAuthentication();
app.UseAuthorization();
app.UseEndpoints(endpoints =>
{
endpoints.MapControllerRoute(
name: "default",
pattern: "{controller=Home}/{action=Index}/{id?}");
});
}
Then the test method in controller(where user should get redirected to after authentication) is below
[Authorize(AuthenticationSchemes = CookieAuthenticationDefaults.AuthenticationScheme)]
public IActionResult Index()
{
return View();
}
To my understanding the order of the pipeline configuration is correct. Now here are the problems I face.
As specified in .AddCookie(option=>) , user doesn't get redirected to login page. However, if I remove the JwtBearerDefaults.AuthenticationScheme from the services.AddAuthorization(…) it gets redirected to login page properly. Why is that?
So I remove JwtBearerDefaults.AuthenticationScheme; which takes me to login and after successful login I can see that HttpContext.User.Identity.IsAuthenticated is set to true. But it doesn't redirect to Home/Index. No errors thrown and in browser console [network tab] it shows a 302 and redirect back to login. Since I have added [Authorize(AuthenticationSchemes = CookieAuthenticationDefaults.AuthenticationScheme)] to Index method in HomeController, I tried removing the scheme and adding [Authorize] and tried again. Still it gets redirected to login page. This is another thing I am confused about.
So I removed everything related to dual scheme authentication and left the ConfigureService() with below code
string connectionString = Configuration.GetConnectionString("default");
services.AddDbContext<AppDBContext>(c => c.UseSqlServer(connectionString));
services.AddIdentity<IdentityUser, IdentityRole>()
.AddEntityFrameworkStores<AppDBContext>();
services.AddControllersWithViews();
Now everything works fine (redirection to login if not authenticated and also redirects to /Home/Index after authorization).
I went through below links as well about multi scheme authentication, but I am still confused with this pipeline configuration.
ASP.NET Core WebAPI Cookie + JWT Authentication
https://wildermuth.com/2017/08/19/Two-AuthorizationSchemes-in-ASP-NET-Core-2
https://mitchelsellers.com/blog/article/using-multiple-authentication-authorization-providers-in-asp-net-core
I need help only to this multi-scheme authentication pipeline configuration part.
Ok, after some research the main issue was;
I have mixed up old ways of registering services in StartUp (asp.net core 3.x). So had to clear up all those. Resources that helped me to do that;
Migrate from ASP.NET Core 3.1 to 5.0
ASP.NET Core Middleware
This cleaned up a lot of unnecessary code since in .Net 5 there were some shortcuts that could be used.
Order of service registrations. This may depend on what kind of services you are using, but in my case the order was something like below:
AddIdentity
setup Cookie & JWT authentication c)
My Multitenant stuff
AddCors
AddModules (will be option for some of you. I use it to load plugins dynamically)
Other stuff (However, even in these places the order might matter depending on
what you do)
The other thing was, I had to remove ConfigureApplicationCookie() since AddIdentity seems to be doing that. Also in AddAuthorization() earlier I had code to specify what are the default schemes i wanted to use (Cookie & JWT). But now I had to remove all that. Reason is Identity takes over the cookie scheme and when I specify below, JWTAuth takes over.
[Authorize(AuthenticationSchemes = JwtBearerDefaults.AuthenticationScheme)]
Generally setting up Startup properly seems to be tricky depending on different services you use. Order will matter

How to redirect to login page in ABP?

I download the .NET Core sample from ASP.NET Boilerplate website,
change the DB connection string, update DB,
run Web Api, show the Swagger successfully,
add a Home/Index View, change Home/Index action to return the View, not Swagger,
run again, show home page successfully,
then add a Home.Account Controller and Home.Account View, login page.
Add AbpMvcAuthentication attribute on Home/Index, what I want is when access Home, redirect to login page.
When I go to the Home, it shows an empty page, not Home, nor login page. It seems authenticate failed, but did not redirect to login page.
My question is: how to let AbpMvcAuthentication know which page to redirect when authenticate failed?
There is no login page for the *.Web.Host project, where the redirect to Swagger is.
You should change your Startup Project to *.Web.Mvc instead:
If you just want to login, consider using Swagger authentication helpers from the browser console:
abp.swagger.login();
To answer your question, you can re-enable authentication redirect by reverting commit 92b6270:
// What it was (and what you want)
services.AddAuthentication()
.AddJwtBearer(options =>
// What it is
services.AddAuthentication(options =>
{
options.DefaultAuthenticateScheme = "JwtBearer";
options.DefaultChallengeScheme = "JwtBearer";
}).AddJwtBearer("JwtBearer", options =>
Startup.cs:
app.UseAuthentication();
app.UseJwtTokenMiddleware(); // Add this back
app.UseAbpRequestLocalization();
To be clear, this redirects to a blank page since there is no login page for the *.Web.Host project.
This is the opposite of the expected behaviour in ABP 401 response from API instead of redirect.
Also, you can configure login path for redirect:
You can configure that in Startup.cs:
IdentityRegistrar.Register(services);
AuthConfigurer.Configure(services, _appConfiguration);
// Add this line:
services.ConfigureApplicationCookie(options => options.LoginPath = "/Admin/Login");
Related docs: https://learn.microsoft.com/en-us/aspnet/core/migration/1x-to-2x/identity-2x