Blazor Server - how to configure for on-premises ADFS Security? - authentication

I have an existing Blazor (Server) app addressing .NET Core 3.1 preview 2.
I need to retrospectively add on-prem ADFS (not Azure) security. I've been trying to follow Microsoft's Authenticate users with WS-Federation in ASP.NET Core and it's stubbornly ignoring the security. The article is of course written for ASP.NET, not Blazor...
What I've done so far is:
public static void ConfigureServices(IServiceCollection services)
{
services.AddIdentity<IdentityUser, IdentityRole>()
.AddEntityFrameworkStores<ApplicationDbContext>()
.AddDefaultTokenProviders();
services.AddAuthentication()
.AddWsFederation(options =>
{
options.MetadataAddress = "https://adfs.Server.com/FederationMetadata/2007-06/FederationMetadata.xml";
options.Wtrealm = "https://localhost:44323/";
});
services.AddAuthorization();
services.AddRazorPages();
services.AddServerSideBlazor();
....
One thing of concern - the DB currently has tables in it supporting an earlier authentication pattern (membership?) (used by the application we're re-writing). It has the tables [AspNetRoles] [AspNetUserClaims] [AspNetUserLogins] [AspNetUserRoles] and [AspNetUsers]. Will any of this get overwritten?
public class ApplicationDbContext : IdentityDbContext
{
public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options)
: base(options)
{
}
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
if (optionsBuilder != null)
{
if (optionsBuilder.IsConfigured == false)
{
IConfigurationRoot configuration = new ConfigurationBuilder()
.SetBasePath(AppDomain.CurrentDomain.BaseDirectory)
.AddJsonFile($"appsettings.{Startup.CurrentEnvironment}.json")
.Build();
optionsBuilder
.UseSqlServer(configuration.GetConnectionString("MyDatabase"),
providerOptions => providerOptions.CommandTimeout(60));
}
}
base.OnConfiguring(optionsBuilder);
}
}
In the Configure method, I've added (though I'm not clear on whether I needed to):
app.UseAuthentication();
app.UseRouting();
app.UseAuthorization();
In the App.razor, I have:
<CascadingAuthenticationState>
<Router AppAssembly="#typeof(Program).Assembly">
<Found Context="routeData">
#*<RouteView RouteData="#routeData" DefaultLayout="#typeof(MainLayout)" />*#
<AuthorizeRouteView RouteData="#routeData" DefaultLayout="#typeof(MainLayout)" />>
</Found>
<NotFound>
<LayoutView Layout="#typeof(MainLayout)">
<p>Sorry, there's nothing at this address.</p>
</LayoutView>
</NotFound>
</Router>
</CascadingAuthenticationState>
And in one of my razor pages (MyPage.razor), I've got:
#page "/myRoute"
#attribute [Authorize]
When I browse to the page with the Autorize attribute, I get the message:
Not authorized
So it's not calling out to my ADFS server. Shouldn't it just do this automatically - the user shouldn't have to click a "log me in" button.
I've referenced the following NuGet packages:
<PackageReference Include="Microsoft.AspNetCore.Authentication.WsFederation" Version="3.1.0-preview2.19528.8" />
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="3.1.0-preview2.19528.8" />
I have even followed Microsoft's example for Use WS-Federation without ASP.NET Core Identity, but with no luck.

This I an older post, but still pops up first in Google for me so..
I had more or less arrived at the same issue. The OP's post in this thread helped me:
Blazor - Securing using ADFS with local DB repository: how/when to hook into SQL
More specifically, the middleware added to Configure() method made a difference. I ended up with this in my solution:
app.UseAuthentication();
app.UseAuthorization();
app.Use(async (context, next) =>
{
ClaimsPrincipal user = context.User;
if (!user.Identities.Any(x => x.IsAuthenticated))
{
await context.ChallengeAsync(WsFederationDefaults.AuthenticationScheme).ConfigureAwait(false);
}
if (next != null)
{
await next().ConfigureAwait(false);
}
});

Related

SignalR no longer works since upgrading to .NET Core 3.1

I have a websocket server application that I recently upgraded from .NET Core 2.1 to 3.1, however, since then the negotiation seems to fail.
This is the error that my console shows.
I tried my best to follow all the Microsoft docs on how best to upgrade to 3.1. In the code below you can find all the packages my C# server currently uses. I know that with 3.1 you don't have to reference the SignalR packages since Microsoft.AspNetCore.App is enough, but I saw on someone else's post that it helped them so I tried to add it to mine (with no luck).
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.App" />
<FrameworkReference Include="Microsoft.AspNetCore.App" />
<PackageReference Include="Microsoft.AspNetCore.SignalR.Core" Version="1.1.0" />
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="3.1.24" />
<PackageReference Include="Microsoft.AspNetCore.Razor.Design" Version="2.1.2" PrivateAssets="All" />
<PackageReference Include="Microsoft.VisualStudio.Azure.Containers.Tools.Targets" Version="1.0.2105168" />
<PackageReference Include="RabbitMQ.Client" Version="5.2.0" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="3.1.10" />
</ItemGroup>
This is the code for my Startup class:
public class Startup
{
public Startup(IConfiguration configuration)
{
Configuration = configuration;
}
public IConfiguration Configuration { get; }
readonly string MyAllowSpecificOrigins = "_myAllowSpecificOrigins";
// This method gets called by the runtime. Use this method to add services to the container.
public void ConfigureServices(IServiceCollection services)
{
services.Configure<ConnectionOptions>(Configuration.GetSection("MQConfig"));
services.AddHostedService<LiveUpdaterService>();
services.AddCors(options =>
{
options.AddPolicy(MyAllowSpecificOrigins,
builder =>
{
builder
.AllowAnyMethod()
.AllowAnyHeader()
.AllowAnyOrigin()
.AllowCredentials();
});
});
//I also tried services.AddSignalR();
services.AddSignalRCore();
services.AddMvc(options => options.EnableEndpointRouting = false)
.SetCompatibilityVersion(CompatibilityVersion.Version_3_0);
}
// This method gets called by the runtime. Use this method to configure the HTTP request pipeline.
public void Configure(IApplicationBuilder app, Microsoft.AspNetCore.Hosting.IHostingEnvironment env)
{
if (env.IsDevelopment())
{
app.UseDeveloperExceptionPage();
}
else
{
//app.UseHsts();
}
app.UseCors(cors =>
{
cors.AllowAnyHeader();
cors.AllowAnyOrigin();
cors.AllowAnyMethod();
cors.AllowCredentials();
});
app.UseStaticFiles();
app.UseRouting();
app.UseAuthentication();
app.UseAuthorization();
app.UseEndpoints(endpoints =>
{
endpoints.MapHub<LiveUpdateHub>("/liveupdatehub", options =>
{
options.Transports =
HttpTransportType.WebSockets |
HttpTransportType.LongPolling;
});
endpoints.MapControllers();
});
app.UseMvc();
}
}
This is the code for my JavaScript client:
import { HubConnectionBuilder, LogLevel } from "#microsoft/signalr";
var connection;
var connected;
export default class LiveDataHub {
connectWebSocket() {
connection = new HubConnectionBuilder()
.withUrl("http://localhost:5003/liveupdatehub")
.configureLogging(LogLevel.Information)
.withAutomaticReconnect()
.build();
//Disable button until connection is established
document.getElementById("runTestButton").disabled = true;
connection.onclose(function (error) {
document.getElementById("runTestButton").disabled = true;
connected = false;
console.log(error)
alert("There was a problem connecting to the backend, please try again");
});
connection
.start()
.then(function () {
document.getElementById("runTestButton").disabled = false;
console.log(connection);
connected = true;
})
.catch(function (err) {
alert("No connection could be made to the backend, please refresh: " + err.toString());
connected = false;
return console.error(err.toString());
});
return connection;
}
So far for the server, I've tried moving the Configure and ConfigureServices methods around because I know their order matters in 3.1, but maybe there's a position I've missed. I've also tried adding a lot of extra Configure methods that I read from other posts like (options => options.EnableEndpointRouting = false) or AddNewtonSoftJson().
For the client I've tried to skip the negotiation part by adding this:
connectWebSocket() {
connection = new HubConnectionBuilder()
.withUrl("ws://localhost:5003/liveupdatehub", {
skipNegotiation: true,
transport: HttpTransportType.WebSockets
})
.configureLogging(LogLevel.Information)
.withAutomaticReconnect()
.build();
But the console error log then changes to this. Which is what leads me to believe that SignalR is the main issue here.
If anyone can help me with this I'd really appreciate it!
So after some time I've discovered what the underlying issue was. It turns out that calling services.AddHostedService() causes the hosted service to be called up before the SignalR connection can be established, and because my LiveUpdaterService relies on SignalR it crashes the whole connection.
After commenting that method out, the negotiation works along with the rest of my app (besides the LiveUpdaterService). I found the answer through this link if anyone is interested: https://github.com/Azure/azure-signalr/issues/909. I'm currently trying to find a workaround fix for this, but at least the connection is working now.
I'll marked this as solved since the AddHostedService is more of a compatibility issue. Hopefully this might help someone else with the same problem.
Final code for my Startup:
public class Startup
{
public Startup(IConfiguration configuration)
{
Configuration = configuration;
}
public IConfiguration Configuration { get; }
readonly string MyAllowSpecificOrigins = "_myAllowSpecificOrigins";
// This method gets called by the runtime. Use this method to add services to the container.
public void ConfigureServices(IServiceCollection services)
{
services.AddRazorPages();
services.AddSignalR();
services.Configure<ConnectionOptions>(Configuration.GetSection("MQConfig"));
services.AddCors(options =>
{
options.AddPolicy("AllowSetOrigins",
builder =>
{
builder
.AllowAnyHeader()
.AllowAnyMethod()
.AllowCredentials()
.WithOrigins("http://localhost:5003")
.WithOrigins("http://localhost:8080");
});
});
//services.AddHostedService<LiveUpdaterService>();
}
// This method gets called by the runtime. Use this method to configure the HTTP request pipeline.
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
if (env.IsDevelopment())
{
app.UseDeveloperExceptionPage();
}
else
{
app.UseExceptionHandler("/Error");
// The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts.
app.UseHsts();
}
//app.UseHttpsRedirection();
app.UseStaticFiles();
app.UseRouting();
app.UseCors("AllowSetOrigins");
app.UseAuthorization();
app.UseAuthentication();
app.UseEndpoints(endpoints =>
{
endpoints.MapRazorPages();
endpoints.MapHub<LiveUpdateHub>("/liveupdatehub");
});
}
}
Final code for my packages:
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.1" />
<PackageReference Include="RabbitMQ.Client" Version="5.2.0" />
</ItemGroup>

Deployed WebAssembly Blazor application doesn't route authentication properly, but locally working

I created a 'normal' WebAssembly Blazor client and server application.
I decided later on to add authentication, so I followed the steps at this address:
https://learn.microsoft.com/en-us/aspnet/core/blazor/security/webassembly/hosted-with-identity-server?view=aspnetcore-3.1&tabs=visual-studio
ending up with this startup code in the server part of the Blazor WebAssembly application:
public class Startup
{
private readonly IWebHostEnvironment _environment;
private readonly IConfiguration _configuration;
public Startup(IWebHostEnvironment environment, IConfiguration configuration)
{
_environment = environment;
_configuration = configuration;
}
public void ConfigureServices(IServiceCollection services)
{
if (_environment.IsDevelopment())
{
services.AddDbContext<ApplicationDbContext>(options =>
options.UseSqlServer(
_configuration.GetConnectionString("LocalEnvironment")));
}
else
{
services.AddDbContext<ApplicationDbContext>(options =>
options.UseSqlServer(
_configuration.GetConnectionString("CloudEnvironment")));
}
services.AddDefaultIdentity<ApplicationUser>()
.AddRoles<ApplicationRole>()
.AddEntityFrameworkStores<ApplicationDbContext>();
services.AddIdentityServer()
.AddApiAuthorization<ApplicationUser, ApplicationDbContext>(options =>
{
options.IdentityResources["openid"].UserClaims.Add("name");
options.ApiResources.Single().UserClaims.Add("name");
options.IdentityResources["openid"].UserClaims.Add("role");
options.ApiResources.Single().UserClaims.Add("role");
});
JwtSecurityTokenHandler.DefaultInboundClaimTypeMap.Remove("role");
services.AddAuthentication()
.AddIdentityServerJwt();
services.AddControllersWithViews();
services.AddRazorPages();
services.Configure<IdentityOptions>(options =>
{
options.ClaimsIdentity.UserIdClaimType = ClaimTypes.NameIdentifier;
options.User.RequireUniqueEmail = true;
options.Password.RequiredLength = 8;
options.Password.RequireNonAlphanumeric = true;
options.Password.RequireLowercase = false;
options.Password.RequireUppercase = true;
options.Password.RequireDigit = true;
});
services.AddTransient<IPasswordValidator<ApplicationUser>, CustomPasswordPolicy>();
services.AddTransient<IUserValidator<ApplicationUser>, CustomUsernameEmailPolicy>();
services.AddTransient<IProfileService, ProfileService>();
services.AddHttpContextAccessor();
services.AddHsts(options =>
{
options.Preload = true;
options.IncludeSubDomains = true;
options.MaxAge = TimeSpan.FromDays(60);
});
}
public void Configure(IApplicationBuilder app, ApplicationDbContext db,
UserManager<ApplicationUser> userManager, RoleManager<ApplicationRole> roleManager)
{
if (_environment.IsDevelopment())
{
app.UseDeveloperExceptionPage();
app.UseDatabaseErrorPage();
app.UseWebAssemblyDebugging();
}
else
{
app.UseExceptionHandler("/Error");
app.UseHsts();
}
db.Database.EnsureCreated();
app.UseHttpsRedirection();
app.UseBlazorFrameworkFiles();
app.UseStaticFiles();
app.UseRouting();
app.UseIdentityServer();
app.UseAuthentication();
app.UseAuthorization();
app.UseEndpoints(endpoints =>
{
endpoints.MapRazorPages();
endpoints.MapControllers();
endpoints.MapFallbackToFile("index.html");
});
IdentityDataInitializer.SeedTestData(userManager, roleManager);
}
}
The resulting application works perfectly when in the Development environment (both in Kestrel and IIS Express, but when I deploy it to an Azure Service App, the authentication part, and only that one, doesn't work properly.
For example: if I click the Login button in the home page, when I'm local there's a jump to the page:
https://localhost:5001/Identity/Account/Login?ReturnUrl=...
That's the correct path, because, moreover, after logging in, I'm redirected correctly to the home page.
But when I click the same button on the deployed application, I see the address becoming first
'.../authentication/login'
and after a few moments, going to
'.../connect/authorize?client_id=Test1.Client&redirect_uri=...'
that's a not existing page.
Personally, I don't even understand, at the moment, if it's a server or client problem, or just the configuration of the service app on Azure...
Please, feel free to ask for other code, or anything that can help.
Thank you in advance.
/connect/authorize is one of the endpoints that IdentityServer listens for and it is the first URL that the application/client should redirect to when the user is about to authenticate.
One way to tell if IdentityServer is up and running is to go to this URL https://yourdomain.com/.well-known/openid-configuration
This URL Should always succeed.
When you deploy to the cloud and Azure Service App, one thing is to make sure you understand where HTTPS is terminated, is it in your application or in Azure Service? If it is not terminated in the application it-self, then it might be that the public URL is HTTPS but what your application sees is HTTP.
Some links to follow:
https://securecloud.blog/2020/07/23/unobvious-stuff-about-azure-services-app-service-tls-termination/
https://www.hanselman.com/blog/SecuringAnAzureAppServiceWebsiteUnderSSLInMinutesWithLetsEncrypt.aspx

Blazor - Securing using ADFS with local DB repository: how/when to hook into SQL

I have a Blazer Server app which now uses authentication from a local ADFS server. Having identified the user, I now need to load their permissions. We don't think this can be provided via claims from the ADFS server, so want to configure this in the DB, but need to understand how/when to get this information.
Regarding the hook into ADFS, my code is as follows (any suggestions on improvement most welcome)
App.razor
<CascadingAuthenticationState>
<Router AppAssembly="#typeof(Program).Assembly">
<Found Context="routeData">
<AuthorizeRouteView RouteData="#routeData" DefaultLayout="#typeof(MainLayout)">
<NotAuthorized>
<h1>Sorry</h1>
<p>You're not authorized to reach this page.</p>
<p>You may need to log in as a different user.</p>
</NotAuthorized>
</AuthorizeRouteView>
</Found>
<NotFound>
<LayoutView Layout="#typeof(MainLayout)">
<h1>Sorry</h1>
<p>Sorry, there's nothing at this address.</p>
</LayoutView>
</NotFound>
</Router>
</CascadingAuthenticationState>
appsettings.Development.json
{
"DetailedErrors": "true",
"ConnectionStrings": {
"MyDB": "Data Source=x.x.x.x;Initial Catalog=xxxxx;user id=me;password=sshhh;Persist Security Info=False;"
},
"Ida": {
"ADFSMetadata": "https://adfs.ourServer.com/FederationMetadata/2007-06/FederationMetadata.xml",
"Wtrealm": "https://localhost:44323/"
}
}
Startup.cs (only showing security related code)
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Authentication.WsFederation;
public class Startup
{
public static void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
.....
app.UseAuthentication();
app.Use(async (context, next) =>
{
context.Response.Headers.Add("X-Frame-Options", "DENY");
var user = context.User;
if (user?.Identities.HasNoItems(identity => identity.IsAuthenticated) ?? true)
{
await context.ChallengeAsync(WsFederationDefaults.AuthenticationScheme).ConfigureAwait(false);
}
if (next != null)
{
await next().ConfigureAwait(false);
}
});
....
}
...
public void ConfigureServices(IServiceCollection services)
{
var wtrealm = this.Configuration.GetSection("Ida:Wtrealm").Value;
var metadataAddress = this.Configuration.GetSection("Ida:ADFSMetadata").Value;
services
.AddAuthentication(sharedOptions =>
{
sharedOptions.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
sharedOptions.DefaultSignInScheme = CookieAuthenticationDefaults.AuthenticationScheme;
sharedOptions.DefaultChallengeScheme = WsFederationDefaults.AuthenticationScheme;
})
.AddWsFederation(options =>
{
options.Wtrealm = wtrealm ;
options.MetadataAddress = metadataAddress;
options.UseTokenLifetime = false;
})
.AddCookie();
....
}
}
Any suggestions regarding the above code? When the user enters our site (any page), they automatically get pushed to the ADFS server to authenticate. Seems okay, but prevents the user from logging out....
So, from ADFS we get several claims that identify the user, e.g. their UPNname. My thought is to go to the DB and load all the roles/permissions/rights that this user has.
Where in my code should I put such code
The DB is currently used by another application that uses the older "membership" tables. I want to use something a bit more up-to-date, the identity model? I can't risk breaking security for the other application. Should I store security in a new DB?
Any pointers would be most welcome...assume I'm a novice at this.
The usual way to do this is to write a custom attribute provider for ADFS.
Then you can get the roles you want from the DB and they are added to the claims.
After reading a lot around this area, I discovered a very clearly presented PluralSight course that solved it for me:
ASP.NET Core 2 Authentication Playbook: Chris Klug
Specifically, see the chapter:
Doing Claims Transformation
For details, watch the course. In summary:
Create the Entity and Context for pulling your data from the DB.
Register these in the Startup in the normal manner.
Create a ProfileService (that has the logic to read the data from the
Context). I also included a MemoryCache object so I could locally
store the info.
Then create a Claims Transformation class (implementing IClaimsTransformation) and register that as a service.
Add these into your Blazor page's code-behind file as:
[Inject]
protected IClaimsTransformation ClaimsTransformation { get; set; } = null!;
[CascadingParameter]
protected Task AuthenticationStateTask { get; set; }
Consume the data thus:
AuthenticationState authState = await this.AuthenticationStateTask;
ClaimsPrincipal user = authState.User;
ClaimsPrincipal x = await this.ClaimsTransformation.TransformAsync(user);

.Net Core 3 Identity register or login links not functional [duplicate]

After having a hard time getting my area to show with endpoint routing i managed to fix it in this self answered thread (albeit not in a very satisfactory way) : Issue after migrating from 2.2 to 3.0, default works but can't access area, is there anyway to debug the endpoint resolution?
However Identity UI doesn't show at all for me, i get redirected on challenge to the proper url but the page is blank. I have the identity UI nugget package added and, changing from mvc routing to endpoint routing, i didn't change anything that should break it.
I also don't seem to do much different than what the default project does and identity works there even if i add a route as i did in my hack.
As often the issue hides around the line and not on it i'm posting my whole startup file.
Regular (default) controllers work.
Admin area works (one of the page doesn't have authentication and i can access it)
Any other Admin area page redirect me to /Identity/Account/Login?ReturnUrl=%2Fback (expected behavior) but that page as well as any other /Identity page i tested is blank with no error while running in debug and with a debugger attached.
Any help is most appreciated, full startup bellow:
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.HttpsPolicy;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using FranceMontgolfieres.Models;
using Microsoft.AspNetCore.Mvc.Infrastructure;
using Microsoft.Extensions.Hosting;
using Microsoft.AspNetCore.Diagnostics.HealthChecks;
namespace FranceMontgolfieres
{
public class Startup
{
public Startup(IConfiguration configuration)
{
Configuration = configuration;
}
public IConfiguration Configuration { get; }
// This method gets called by the runtime. Use this method to add services to the container.
public void ConfigureServices(IServiceCollection services)
{
services.AddSingleton<IConfiguration>(Configuration);
services
.Configure<CookiePolicyOptions>(options =>
{
// This lambda determines whether user consent for non-essential cookies is needed for a given request.
options.CheckConsentNeeded = context => true;
options.MinimumSameSitePolicy = SameSiteMode.None;
});
services
.AddDbContext<FMContext>(options => options
.UseLazyLoadingProxies(true)
.UseSqlServer(Configuration.GetConnectionString("DefaultConnection")));
services
.AddDefaultIdentity<IdentityUser>()
.AddRoles<IdentityRole>()
.AddEntityFrameworkStores<FMContext>();
services
.AddMemoryCache();
services.AddDistributedSqlServerCache(options =>
{
options.ConnectionString = Configuration.GetConnectionString("SessionConnection");
options.SchemaName = "dbo";
options.TableName = "SessionCache";
});
services.AddHttpContextAccessor();
services
.AddSession(options => options.IdleTimeout = TimeSpan.FromMinutes(30));
services.AddControllersWithViews();
services.AddRazorPages();
}
// This method gets called by the runtime. Use this method to configure the HTTP request pipeline.
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
if (env.IsDevelopment())
{
app.UseDeveloperExceptionPage();
app.UseDatabaseErrorPage();
}
else
{
app.UseExceptionHandler("/Home/Error");
// The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts.
app.UseHsts();
}
app.UseHttpsRedirection();
app.UseRouting();
app.UseStaticFiles();
app.UseCookiePolicy();
app.UseAuthentication();
app.UseAuthorization();
app.UseSession();
app.UseEndpoints(endpoints =>
{
endpoints.MapAreaControllerRoute("Back", "Back", "back/{controller=Home}/{action=Index}/{id?}");
endpoints.MapControllerRoute("default","{controller=Home}/{action=Index}/{id?}");
});
}
private async Task CreateRoles(IServiceProvider serviceProvider)
{
//initializing custom roles
var RoleManager = serviceProvider.GetRequiredService<RoleManager<IdentityRole>>();
string[] roleNames = { "Admin", "Manager", "Member" };
IdentityResult roleResult;
foreach (var roleName in roleNames)
{
roleResult = await RoleManager.CreateAsync(new IdentityRole(roleName));
}
}
}
}
The Identity UI is implemented using Razor Pages. For endpoint-routing to map these, add a call to MapRazorPages in your UseEndpoints callback:
app.UseEndpoints(endpoints =>
{
// ...
endpoints.MapRazorPages();
});

A single login page for multiple authentication types (including Azure AD) in .NET Core MVC

I have a .NET Core 2.2 MVC web-application. And I've added two authentication types/providers there:
Login/password with local users database (custom thing, without .NET Core Identity)
Azure AD
My goal is to have a login page at /account/login where users can choose between these two authentications and log-in with either of those. So every time an unauthenticated user would open any page (from a controller with [Authorize] attrubite), he would get redirected to /account/login page, which has a login/password web-form with its own submit button, and additionally a Office 365 login link/button.
Just to make it clear - I don't want a custom Microsoft sign-in / Azure AD page. I only want unauthenticated users to get my login page first, from where they can either log-in using my web-form or click on Office 365 login and get to Microsoft sign-in page.
Now, the authentication part is done and seems to work fine, I can log-in with either of authentications, but my plan with redirecting unauthenticated user to /account/login failed. What happens instead is that user is being redirected to Microsoft sign-in page right away. So it looks like Azure AD authentication has a higher priority somehow.
Here's my implementation.
Startup.cs:
// ...
public void ConfigureServices(IServiceCollection services)
{
services.Configure<CookiePolicyOptions>(options =>
{
options.MinimumSameSitePolicy = SameSiteMode.None;
});
// the presence of CookieAuthenticationDefaults.AuthenticationScheme doesn't seem to influence anything
services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
// makes no difference either
//services.AddAuthentication(
// options =>
// {
// options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
// options.DefaultChallengeScheme = CookieAuthenticationDefaults.AuthenticationScheme;
// options.DefaultSignInScheme = CookieAuthenticationDefaults.AuthenticationScheme;
// }
//)
.AddCookie(CookieAuthenticationDefaults.AuthenticationScheme, // and it also can be omitted here
options =>
{
options.LoginPath = "/Account/Login";
options.ExpireTimeSpan = TimeSpan.FromDays(45);
})
.AddAzureAD(options => _configuration.Bind("AzureAD", options));
services.AddAuthorization(options =>
{
// as the default policy, it applies to all [Authorize] controllers
options.DefaultPolicy = new AuthorizationPolicyBuilder(
CookieAuthenticationDefaults.AuthenticationScheme,
AzureADDefaults.AuthenticationScheme
)
.RequireAuthenticatedUser() // a simple policy that only requires a user to be authenticated
.Build();
});
services.Configure<OpenIdConnectOptions>(AzureADDefaults.OpenIdScheme, options =>
{
options.Authority = options.Authority + "/v2.0/";
options.TokenValidationParameters.ValidateIssuer = false;
});
services.AddMvc(options =>
{
// it is my understanding that there is no need create a policy here
// and perform "options.Filters.Add(new AuthorizeFilter(policy))",
// because the default policy is already added and controllers have explicit [Authorize] attribute
// [...] well, actually I tried that too, but it didn't change anything
options.Filters.Add(new AutoValidateAntiforgeryTokenAttribute());
})
.SetCompatibilityVersion(CompatibilityVersion.Version_2_2);
}
public void Configure(
IApplicationBuilder app,
IHostingEnvironment env,
ILoggerFactory loggerFactory
)
{
// ...
app.UseCookiePolicy();
app.UseAuthentication();
app.UseMvc(routes =>
{
routes.MapRoute(
name: "default",
template: "{controller=Home}/{action=Index}/{id?}"
);
});
}
AccountController.cs:
[Authorize]
[Route("account")]
public class AccountController : Controller
{
// ...
// that is where "Office 365 login" link leads
[HttpGet("login-ad")]
[AllowAnonymous]
public IActionResult LoginAD(string returnUrl = null)
{
if (User.Identity.IsAuthenticated)
{
return RedirectToAction("Index", "Account");
}
else
{
if (string.IsNullOrEmpty(returnUrl)) { returnUrl = "/"; }
return Challenge(
new AuthenticationProperties { RedirectUri = returnUrl },
AzureADDefaults.AuthenticationScheme
);
}
}
[HttpGet("login")]
[AllowAnonymous]
public IActionResult Login(string returnUrl = null)
{
if (User.Identity.IsAuthenticated)
{
return RedirectToAction("Index", "Account");
}
ViewData["ReturnUrl"] = returnUrl;
return View();
}
// that is where login/password web-form submits to
[HttpPost("login")]
[AllowAnonymous]
public async Task<IActionResult> Login(LoginViewModel model, string returnUrl = null)
{
if (ModelState.IsValid)
{
await _usersManager.SignIn(
model.Login,
model.Password
);
// ...
return LocalRedirect(returnUrl);
}
ViewData["ReturnUrl"] = returnUrl;
return View(model);
}
// ...
}
HomeController.cs:
[Authorize]
public class HomeController : Controller
{
// ...
public IActionResult Index()
{
return View();
}
// ...
}
So, opening any page by an unauthenticated user results in immediate redirect to Microsoft sign-in page. And in order to get to /account/login (to have a chance to log-in using another authentication) users have to open that URL explicitly.
If I remove AzureADDefaults.AuthenticationScheme from default policy, then all unauthenticated requests will now get redirected to /account/login - exactly what I want - but naturally Azure AD authentication doesn't work anymore:
These redirects tell me that after successful authentication at Microsoft sign-in page it returns user back to /account/login, but user is still not authenticated on my website.
I can of course add [AllowAnonymous] to Index action of HomeController and return redirect to /account/login for unauthenticated users, but that obviously would only work for / route.
I have a feeling that I don't understand some things about AddAuthentication(), schemes and policies, thus apparently I did something wrong in Startup.cs. Can you please help me to understand what's wrong there? Or maybe there is some other way to achieve what I want?
Updated answer
I decided to clone the example project mentioned here in the quickstart-v2-aspnet-core-webapp documentation and see if I could reproduce your error.
After cloning the project I added two NuGet packages.
Microsoft.AspNetCore.Identity 2.2.0
Microsoft.AspNetCore.Identity.EntityFrameworkCore 2.2.0
Then added the database context that extends IdentityContext.
ApplicationDbContext.cs
In Startup.cs
Registered Identity
Registered the database context and provided connection string
In AppSettings.json
Configured TenantID and ClientID
Ran the application.
At this point, the app launches and redirects me to Account/Login, where I choose Sign in via Microsoft account.
Now, I can obviously see there is something wrong. It wouldn't authenticate the user.
Turns out:
The extension method .AddAzureAd() actually cannot be used in combination with other authentication methods. See this issue on github.
But luckily the workaround is fairly simple. Just switch out .AddAzureAd() for .AddOpenIdConnect() and change your AppSettings' AzureAd section to:
"AzureAd": {
"Instance": "https://login.microsoftonline.com/",
"Authority": "https://login.microsoftonline.com/{tenantID}/v2.0/",
"TenantId": "{tenantID}",
"ClientId": "{clientID}",
"CallbackPath": "/signin-oidc"
},
Now I can log in perfectly fine with AzureAD and local user accounts as well.
For your convenience, I uploaded the complete example project to my GitHub page.